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">',
|
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
||||||
f'<style>\n{css}\n</style>')
|
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()
|
js = open(os.path.join(BASE, 'js', jsf)).read()
|
||||||
html = html.replace(f'<script src="js/{jsf}"></script>',
|
html = html.replace(f'<script src="js/{jsf}"></script>',
|
||||||
f'<script>\n{js}\n</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 id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="score"></div>
|
<div id="score"></div>
|
||||||
|
<div id="pronounce"></div>
|
||||||
<div id="diff"></div>
|
<div id="diff"></div>
|
||||||
<div id="syllables"></div>
|
<div id="syllables"></div>
|
||||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></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) { /* тихо */ }
|
} 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>
|
||||||
<script>
|
<script>
|
||||||
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||||
@@ -852,7 +954,7 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
}
|
}
|
||||||
clearInterval(timerId);
|
clearInterval(timerId);
|
||||||
|
|
||||||
let recHTML = '', diffHTML = '';
|
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||||
|
|
||||||
if (whisperResult.text) {
|
if (whisperResult.text) {
|
||||||
const _transcribed = whisperResult.text;
|
const _transcribed = whisperResult.text;
|
||||||
@@ -864,13 +966,21 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
renderDiff(words);
|
renderDiff(words);
|
||||||
|
|
||||||
|
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||||
|
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||||
|
if (assess) {
|
||||||
|
const el = document.getElementById('pronounce');
|
||||||
|
if (el) el.innerHTML = renderPronunciation(assess);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
recHTML = '⚠️ Не распознано';
|
recHTML = '⚠️ Не распознано';
|
||||||
diffHTML = '';
|
diffHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('score').innerHTML = recHTML;
|
document.getElementById('score').innerHTML = recHTML;
|
||||||
|
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||||
document.getElementById('diff').innerHTML = diffHTML;
|
document.getElementById('diff').innerHTML = diffHTML;
|
||||||
|
|
||||||
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
||||||
@@ -925,7 +1035,7 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
<script>
|
<script>
|
||||||
// ---------- Глобальные переменные и инициализация ----------
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
|
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
|
||||||
const VERSION = 'v106';
|
const VERSION = 'v107';
|
||||||
|
|
||||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
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 id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="score"></div>
|
<div id="score"></div>
|
||||||
|
<div id="pronounce"></div>
|
||||||
<div id="diff"></div>
|
<div id="diff"></div>
|
||||||
<div id="syllables"></div>
|
<div id="syllables"></div>
|
||||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></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/storage.js"></script>
|
||||||
<script src="js/syllables.js"></script>
|
<script src="js/syllables.js"></script>
|
||||||
<script src="js/transcribe.js"></script>
|
<script src="js/transcribe.js"></script>
|
||||||
|
<script src="js/pronounce.js"></script>
|
||||||
<script src="js/audio.js"></script>
|
<script src="js/audio.js"></script>
|
||||||
<script src="js/compare.js"></script>
|
<script src="js/compare.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
|||||||
+10
-2
@@ -65,7 +65,7 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
}
|
}
|
||||||
clearInterval(timerId);
|
clearInterval(timerId);
|
||||||
|
|
||||||
let recHTML = '', diffHTML = '';
|
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||||
|
|
||||||
if (whisperResult.text) {
|
if (whisperResult.text) {
|
||||||
const _transcribed = whisperResult.text;
|
const _transcribed = whisperResult.text;
|
||||||
@@ -77,13 +77,21 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
renderDiff(words);
|
renderDiff(words);
|
||||||
|
|
||||||
|
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||||
|
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||||
|
if (assess) {
|
||||||
|
const el = document.getElementById('pronounce');
|
||||||
|
if (el) el.innerHTML = renderPronunciation(assess);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
recHTML = '⚠️ Не распознано';
|
recHTML = '⚠️ Не распознано';
|
||||||
diffHTML = '';
|
diffHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('score').innerHTML = recHTML;
|
document.getElementById('score').innerHTML = recHTML;
|
||||||
|
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||||
document.getElementById('diff').innerHTML = diffHTML;
|
document.getElementById('diff').innerHTML = diffHTML;
|
||||||
|
|
||||||
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
// ---------- Глобальные переменные и инициализация ----------
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
const GROQ_API_KEY = '';
|
const GROQ_API_KEY = '';
|
||||||
const VERSION = 'v106';
|
const VERSION = 'v107';
|
||||||
|
|
||||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
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