// ---------- Сравнение текстов (Левенштейн) и 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 || ''; }