// ---------- Сравнение текстов (Левенштейн) и doCompare ----------
function normalize(text) {
return text.toLowerCase().replace(/[.,!?;:«»"'’]/g, '').trim();
}
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m+1}, (_,i) => [i]);
for (let j=0; j<=n; j++) dp[0][j] = j;
for (let i=1; i<=m; i++)
for (let j=1; j<=n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[m][n];
}
function wordSimilarity(a, b) {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
return 1 - levenshtein(a, b) / maxLen;
}
function compareTexts(original, transcribed) {
const a = normalize(original).split(/\s+/);
const b = normalize(transcribed).split(/\s+/);
let totalScore = 0;
const result = a.map(word => {
let best = 0, bestMatch = '';
for (const t of b) {
const sim = wordSimilarity(word, t);
if (sim > best) { best = sim; bestMatch = t; }
}
totalScore += best;
const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad';
return { word, quality, bestMatch, score: Math.round(best*100) };
});
const score = Math.round((totalScore / a.length) * 100);
return { score, words: result };
}
function renderDiff(words) {
return words.map(({ word, quality, bestMatch, score }) => {
const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red';
const tip = quality !== 'ok' ? ` → ${bestMatch} (${score}%)` : '';
return `${word}${tip}`;
}).join(' ');
}
async function doCompare(blob, originalText, duration) {
if (isAnalyzing) return '';
isAnalyzing = true;
const startTime = Date.now();
const timerId = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
setStatus('Анализ... ' + elapsed + 'с');
}, 150);
let whisperResult;
try {
whisperResult = await transcribe(blob);
} catch(e) {
clearInterval(timerId);
isAnalyzing = false;
setStatus('Ошибка анализа.');
return '';
}
clearInterval(timerId);
let recHTML = '', diffHTML = '', pronounceHTML = '';
if (whisperResult.text) {
const _transcribed = whisperResult.text;
const { score, words } = compareTexts(originalText, whisperResult.text);
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
recHTML = recEmoji + ' Распознание: ' + score + '% | 🗣 ' + whisperResult.text + '';
diffHTML =
'
' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
'
' +
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 перезаписывается)
_currentBlob = blob;
_currentSylBuf = null;
_speechOnset = 0;
_speechEnd = 0;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null;
console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = '';
// Показываем отладочную инфу: диапазон речи и первый/последний слог
if (_currentSylMap && _currentSylMap.length > 0) {
const first = _currentSylMap[0], last = _currentSylMap[_currentSylMap.length - 1];
const dbg = document.createElement('div');
dbg.style.cssText = 'font-size:0.7em;color:#aaa;margin-top:2px';
dbg.textContent = '⏱ речь: ' + (first.start||0).toFixed(2) + '–' + (last.end||0).toFixed(2) + 'с | ' +
_currentSylMap.map(s => s.syl + '[' + (s.start||0).toFixed(2) + ']').join(' ');
sylDiv.appendChild(dbg);
}
const sy = syllabifyIT(originalText);
if (sy && sy.includes('-')) {
const syls = sy.split('-');
const div = document.createElement('div');
div.style.cssText = 'margin-top:8px;font-size:1.2em;letter-spacing:2px;user-select:none';
syls.forEach((s, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.style.cssText = 'color:#ccc;margin:0 1px';
sep.textContent = '-';
div.appendChild(sep);
}
const chip = document.createElement('span');
chip.textContent = s;
chip.style.cssText = 'cursor:pointer;padding:2px 6px;border-radius:4px;transition:background 0.1s';
chip.onmouseover = () => { chip.style.background = '#d0d0ff'; };
chip.onmouseout = () => { chip.style.background = ''; };
chip.onclick = () => { chip.style.background = '#a0a0ff'; setTimeout(() => { chip.style.background = '#d0d0ff'; }, 200); playSyllable(i, syls.length); };
div.appendChild(chip);
});
sylDiv.appendChild(div);
}
setStatus('Готово.');
isAnalyzing = false;
return whisperResult.text || '';
}