v107: pronunciation assessment UI — score, phonemes, rhythm, feedback в браузере

This commit is contained in:
“Naeel”
2026-05-23 18:08:28 +04:00
parent 415b27edc2
commit 450e95eb19
6 changed files with 225 additions and 7 deletions
+1 -1
View File
@@ -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>')
+113 -3
View File
@@ -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%, красный &lt;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;
+2
View File
@@ -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
View File
@@ -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%, красный &lt;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
View File
@@ -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;
+98
View File
@@ -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;
}