v107: pronunciation assessment UI — score, phonemes, rhythm, feedback в браузере
This commit is contained in:
@@ -9,7 +9,7 @@ css = open(os.path.join(BASE, 'css/style.css')).read()
|
||||
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
||||
f'<style>\n{css}\n</style>')
|
||||
|
||||
for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'audio.js', 'compare.js', 'main.js']:
|
||||
for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'pronounce.js', 'audio.js', 'compare.js', 'main.js']:
|
||||
js = open(os.path.join(BASE, 'js', jsf)).read()
|
||||
html = html.replace(f'<script src="js/{jsf}"></script>',
|
||||
f'<script>\n{js}\n</script>')
|
||||
|
||||
Vendored
+113
-3
@@ -48,6 +48,7 @@ button:disabled { background:#ccc; cursor:not-allowed; }
|
||||
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||
</div>
|
||||
<div id="score"></div>
|
||||
<div id="pronounce"></div>
|
||||
<div id="diff"></div>
|
||||
<div id="syllables"></div>
|
||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
||||
@@ -432,6 +433,107 @@ async function translateToRussian(text) {
|
||||
} catch(e) { /* тихо */ }
|
||||
}
|
||||
|
||||
</script>
|
||||
<script>
|
||||
// ---------- Pronunciation Assessment ----------
|
||||
|
||||
async function assessPronunciation(expectedText, whisperResult) {
|
||||
try {
|
||||
const r = await fetch('/pronounce/assess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected: expectedText,
|
||||
whisper: whisperResult,
|
||||
lang: 'it'
|
||||
})
|
||||
});
|
||||
if (!r.ok) { console.log('[PRONOUNCE] HTTP', r.status); return null; }
|
||||
const data = await r.json();
|
||||
console.log('[PRONOUNCE] score=' + data.overall_score + ' ' + data.quality);
|
||||
return data;
|
||||
} catch(e) {
|
||||
console.log('[PRONOUNCE] ERR', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPronunciation(assess) {
|
||||
if (!assess) return '';
|
||||
|
||||
const s = assess.overall_score;
|
||||
const emoji = s >= 90 ? '🟢' : s >= 70 ? '🟡' : s >= 50 ? '🟠' : '🔴';
|
||||
|
||||
let html = '<div style="margin:10px 0;padding:12px;background:#1a1a2e;border-radius:8px;color:#e0e0e0">';
|
||||
|
||||
// Score bar
|
||||
const barColor = s >= 90 ? '#4caf50' : s >= 70 ? '#ff9800' : s >= 50 ? '#f44336' : '#9e9e9e';
|
||||
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">';
|
||||
html += '<span style="font-size:2em">' + emoji + '</span>';
|
||||
html += '<div style="flex:1">';
|
||||
html += '<div style="font-size:1.4em;font-weight:bold">Произношение: <span style="color:' + barColor + '">' + s + '%</span></div>';
|
||||
html += '<div style="color:#aaa;font-size:0.9em">' + (assess.quality || '') + '</div>';
|
||||
// Bar
|
||||
html += '<div style="height:6px;background:#333;border-radius:3px;margin-top:6px">';
|
||||
html += '<div style="width:' + s + '%;height:100%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div>';
|
||||
html += '</div></div>';
|
||||
|
||||
// Details grid
|
||||
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.85em">';
|
||||
|
||||
// Phonemes
|
||||
if (assess.phoneme_comparison) {
|
||||
const pc = assess.phoneme_comparison;
|
||||
html += '<div><b>🔤 Фонемы:</b> ' + pc.accuracy + '%</div>';
|
||||
html += '<div><b>✓ Совпадений:</b> ' + (pc.matches || 0) + '/' + (pc.total_phonemes || 0) + '</div>';
|
||||
if (pc.errors && pc.errors.length > 0) {
|
||||
const subs = pc.errors.filter(e => e.type === 'sub');
|
||||
const dels = pc.errors.filter(e => e.type === 'del');
|
||||
const inss = pc.errors.filter(e => e.type === 'ins');
|
||||
html += '<div style="grid-column:1/-1">';
|
||||
if (subs.length) html += '<span style="color:#ff9800">Замен: ' + subs.map(e => e.expected + '→' + e.actual).join(', ') + '</span> ';
|
||||
if (dels.length) html += '<span style="color:#f44336">Пропущено: ' + dels.map(e => e.expected).join(', ') + '</span> ';
|
||||
if (inss.length) html += '<span style="color:#9c27b0">Лишних: ' + inss.map(e => e.actual).join(', ') + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Timing
|
||||
if (assess.timing) {
|
||||
const tm = assess.timing;
|
||||
html += '<div><b>⏱ Ритм:</b> ' + (tm.rhythm_score || 0) + '%</div>';
|
||||
html += '<div><b>📏 Длит-ть:</b> ' + (tm.total_duration || 0).toFixed(1) + 'с</div>';
|
||||
if (tm.timing_quality) {
|
||||
const tq = tm.timing_quality;
|
||||
html += '<div style="grid-column:1/-1;color:' + (tq === 'good' ? '#4caf50' : tq === 'ok' ? '#ff9800' : '#f44336') + '">';
|
||||
html += tq === 'good' ? '✅ Ритм ровный' : tq === 'ok' ? '⚠️ Ритм неровный' : '❌ Ритм сбит';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Feedback
|
||||
if (assess.feedback) {
|
||||
html += '<div style="margin-top:8px;padding:6px 10px;background:#2a2a3e;border-radius:4px;font-size:0.9em;color:#ccc">';
|
||||
html += '💬 ' + assess.feedback;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Phoneme detail
|
||||
if (assess.expected && assess.expected.phonemes) {
|
||||
html += '<div style="margin-top:6px;font-size:0.75em;color:#666">';
|
||||
html += '🎯 Эталон: /' + assess.expected.phonemes.join(' ') + '/';
|
||||
if (assess.actual && assess.actual.phonemes) {
|
||||
html += ' | 🗣 Вы: /' + assess.actual.phonemes.join(' ') + '/';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
</script>
|
||||
<script>
|
||||
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||
@@ -852,7 +954,7 @@ async function doCompare(blob, originalText, duration) {
|
||||
}
|
||||
clearInterval(timerId);
|
||||
|
||||
let recHTML = '', diffHTML = '';
|
||||
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||
|
||||
if (whisperResult.text) {
|
||||
const _transcribed = whisperResult.text;
|
||||
@@ -864,13 +966,21 @@ async function doCompare(blob, originalText, duration) {
|
||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||
'</div>' +
|
||||
renderDiff(words);
|
||||
|
||||
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||
if (assess) {
|
||||
const el = document.getElementById('pronounce');
|
||||
if (el) el.innerHTML = renderPronunciation(assess);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
recHTML = '⚠️ Не распознано';
|
||||
diffHTML = '';
|
||||
}
|
||||
|
||||
document.getElementById('score').innerHTML = recHTML;
|
||||
|
||||
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||
document.getElementById('diff').innerHTML = diffHTML;
|
||||
|
||||
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
||||
@@ -925,7 +1035,7 @@ async function doCompare(blob, originalText, duration) {
|
||||
<script>
|
||||
// ---------- Глобальные переменные и инициализация ----------
|
||||
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
|
||||
const VERSION = 'v106';
|
||||
const VERSION = 'v107';
|
||||
|
||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||
</div>
|
||||
<div id="score"></div>
|
||||
<div id="pronounce"></div>
|
||||
<div id="diff"></div>
|
||||
<div id="syllables"></div>
|
||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
||||
@@ -38,6 +39,7 @@
|
||||
<script src="js/storage.js"></script>
|
||||
<script src="js/syllables.js"></script>
|
||||
<script src="js/transcribe.js"></script>
|
||||
<script src="js/pronounce.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/compare.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
+10
-2
@@ -65,7 +65,7 @@ async function doCompare(blob, originalText, duration) {
|
||||
}
|
||||
clearInterval(timerId);
|
||||
|
||||
let recHTML = '', diffHTML = '';
|
||||
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||
|
||||
if (whisperResult.text) {
|
||||
const _transcribed = whisperResult.text;
|
||||
@@ -77,13 +77,21 @@ async function doCompare(blob, originalText, duration) {
|
||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||
'</div>' +
|
||||
renderDiff(words);
|
||||
|
||||
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||
if (assess) {
|
||||
const el = document.getElementById('pronounce');
|
||||
if (el) el.innerHTML = renderPronunciation(assess);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
recHTML = '⚠️ Не распознано';
|
||||
diffHTML = '';
|
||||
}
|
||||
|
||||
document.getElementById('score').innerHTML = recHTML;
|
||||
|
||||
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||
document.getElementById('diff').innerHTML = diffHTML;
|
||||
|
||||
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// ---------- Глобальные переменные и инициализация ----------
|
||||
const GROQ_API_KEY = '';
|
||||
const VERSION = 'v106';
|
||||
const VERSION = 'v107';
|
||||
|
||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// ---------- Pronunciation Assessment ----------
|
||||
|
||||
async function assessPronunciation(expectedText, whisperResult) {
|
||||
try {
|
||||
const r = await fetch('/pronounce/assess', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expected: expectedText,
|
||||
whisper: whisperResult,
|
||||
lang: 'it'
|
||||
})
|
||||
});
|
||||
if (!r.ok) { console.log('[PRONOUNCE] HTTP', r.status); return null; }
|
||||
const data = await r.json();
|
||||
console.log('[PRONOUNCE] score=' + data.overall_score + ' ' + data.quality);
|
||||
return data;
|
||||
} catch(e) {
|
||||
console.log('[PRONOUNCE] ERR', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPronunciation(assess) {
|
||||
if (!assess) return '';
|
||||
|
||||
const s = assess.overall_score;
|
||||
const emoji = s >= 90 ? '🟢' : s >= 70 ? '🟡' : s >= 50 ? '🟠' : '🔴';
|
||||
|
||||
let html = '<div style="margin:10px 0;padding:12px;background:#1a1a2e;border-radius:8px;color:#e0e0e0">';
|
||||
|
||||
// Score bar
|
||||
const barColor = s >= 90 ? '#4caf50' : s >= 70 ? '#ff9800' : s >= 50 ? '#f44336' : '#9e9e9e';
|
||||
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">';
|
||||
html += '<span style="font-size:2em">' + emoji + '</span>';
|
||||
html += '<div style="flex:1">';
|
||||
html += '<div style="font-size:1.4em;font-weight:bold">Произношение: <span style="color:' + barColor + '">' + s + '%</span></div>';
|
||||
html += '<div style="color:#aaa;font-size:0.9em">' + (assess.quality || '') + '</div>';
|
||||
// Bar
|
||||
html += '<div style="height:6px;background:#333;border-radius:3px;margin-top:6px">';
|
||||
html += '<div style="width:' + s + '%;height:100%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div>';
|
||||
html += '</div></div>';
|
||||
|
||||
// Details grid
|
||||
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.85em">';
|
||||
|
||||
// Phonemes
|
||||
if (assess.phoneme_comparison) {
|
||||
const pc = assess.phoneme_comparison;
|
||||
html += '<div><b>🔤 Фонемы:</b> ' + pc.accuracy + '%</div>';
|
||||
html += '<div><b>✓ Совпадений:</b> ' + (pc.matches || 0) + '/' + (pc.total_phonemes || 0) + '</div>';
|
||||
if (pc.errors && pc.errors.length > 0) {
|
||||
const subs = pc.errors.filter(e => e.type === 'sub');
|
||||
const dels = pc.errors.filter(e => e.type === 'del');
|
||||
const inss = pc.errors.filter(e => e.type === 'ins');
|
||||
html += '<div style="grid-column:1/-1">';
|
||||
if (subs.length) html += '<span style="color:#ff9800">Замен: ' + subs.map(e => e.expected + '→' + e.actual).join(', ') + '</span> ';
|
||||
if (dels.length) html += '<span style="color:#f44336">Пропущено: ' + dels.map(e => e.expected).join(', ') + '</span> ';
|
||||
if (inss.length) html += '<span style="color:#9c27b0">Лишних: ' + inss.map(e => e.actual).join(', ') + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Timing
|
||||
if (assess.timing) {
|
||||
const tm = assess.timing;
|
||||
html += '<div><b>⏱ Ритм:</b> ' + (tm.rhythm_score || 0) + '%</div>';
|
||||
html += '<div><b>📏 Длит-ть:</b> ' + (tm.total_duration || 0).toFixed(1) + 'с</div>';
|
||||
if (tm.timing_quality) {
|
||||
const tq = tm.timing_quality;
|
||||
html += '<div style="grid-column:1/-1;color:' + (tq === 'good' ? '#4caf50' : tq === 'ok' ? '#ff9800' : '#f44336') + '">';
|
||||
html += tq === 'good' ? '✅ Ритм ровный' : tq === 'ok' ? '⚠️ Ритм неровный' : '❌ Ритм сбит';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Feedback
|
||||
if (assess.feedback) {
|
||||
html += '<div style="margin-top:8px;padding:6px 10px;background:#2a2a3e;border-radius:4px;font-size:0.9em;color:#ccc">';
|
||||
html += '💬 ' + assess.feedback;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Phoneme detail
|
||||
if (assess.expected && assess.expected.phonemes) {
|
||||
html += '<div style="margin-top:6px;font-size:0.75em;color:#666">';
|
||||
html += '🎯 Эталон: /' + assess.expected.phonemes.join(' ') + '/';
|
||||
if (assess.actual && assess.actual.phonemes) {
|
||||
html += ' | 🗣 Вы: /' + assess.actual.phonemes.join(' ') + '/';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user