From 2d94980038a1fa2cd8a7b00699a9a65fe935dd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 23 May 2026 17:14:45 +0400 Subject: [PATCH] =?UTF-8?q?v106:=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=81=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B0=D0=BC=D0=BF=D0=BB=D0=B8=D1=82=D1=83=D0=B4?= =?UTF-8?q?=D0=B5=20=D0=B0=D1=83=D0=B4=D0=B8=D0=BE=20=E2=80=94=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=D0=BE=D0=B4=D0=B8=D0=BC=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/index.html | 132 ++++++++++++++++++++++++++---------------------- js/compare.js | 11 ++++ js/main.js | 2 +- js/syllables.js | 119 ++++++++++++++++++++++--------------------- 4 files changed, 142 insertions(+), 122 deletions(-) diff --git a/dist/index.html b/dist/index.html index 814d365..b44494d 100644 --- a/dist/index.html +++ b/dist/index.html @@ -217,67 +217,34 @@ function syllabifyPhrase(text) { // whisperWords: [{word, start, end}, ...] // originalText: исходный текст (что пользователь должен был сказать) // Возвращает: [{syl, start, end}, ...] для каждого слога +// Принцип: берём общий диапазон речи [первое_слово.start, последнее_слово.end] +// и пропорционально делим его по длине слогов исходного текста function mapSyllablesToTimestamps(originalText, whisperWords) { const syllables = syllabifyIntoArray(originalText); - if (!whisperWords || whisperWords.length === 0) return syllables.map(s => ({ syl: s, start: null, end: null })); - - // Собираем полный текст из whisper-слов и суммарную длительность - const fullText = whisperWords.map(w => w.word).join(' '); - const norm = s => s.toLowerCase().replace(/[.,!?;:'"\u00AB\u00BB]/g, '').trim(); - - // Строим карту: для каждого whisper-слова — его слоги и время - const wordSyllables = []; - for (const ww of whisperWords) { - const wSyls = syllabifyIntoArray(norm(ww.word)); - wordSyllables.push({ syls: wSyls, start: ww.start, end: ww.end, text: ww.word }); + if (!whisperWords || whisperWords.length === 0) { + return syllables.map(s => ({ syl: s, start: null, end: null })); } - // Сопоставляем слоги исходного текста с whisper-словами - const result = []; - let wsIdx = 0, wsSylIdx = 0; - - for (const syl of syllables) { - const normSyl = norm(syl); - // Ищем этот слог в whisper-словах (продвигаемся вперёд) - while (wsIdx < wordSyllables.length) { - const ws = wordSyllables[wsIdx]; - if (wsSylIdx < ws.syls.length) { - const wsSyl = norm(ws.syls[wsSylIdx]); - // Сравниваем слоги (приблизительно — по первым буквам или полному совпадению) - const match = wsSyl === normSyl || wsSyl.startsWith(normSyl) || normSyl.startsWith(wsSyl); - if (match || wsSylIdx >= ws.syls.length - 1) { - // Вычисляем временной диапазон слога внутри whisper-слова - const totalChars = ws.syls.reduce((sum, s) => sum + s.length, 0); - let charsBefore = 0; - for (let k = 0; k < wsSylIdx; k++) charsBefore += ws.syls[k].length; - const sylRatio = ws.syls[wsSylIdx].length / totalChars; - const wordDur = ws.end - ws.start; - const start = ws.start + (charsBefore / totalChars) * wordDur; - const end = start + sylRatio * wordDur; - result.push({ syl, start, end }); - wsSylIdx++; - if (wsSylIdx >= ws.syls.length) { - wsIdx++; - wsSylIdx = 0; - } - break; - } - wsSylIdx++; - if (wsSylIdx >= ws.syls.length) { - wsIdx++; - wsSylIdx = 0; - } - } else { - wsIdx++; - wsSylIdx = 0; - } - } - if (result.length < syllables.indexOf(syl) + 1) { - // Не нашли сопоставление — fallback - result.push({ syl, start: null, end: null }); - } + // Диапазон речи (от первого до последнего слова) + const speechStart = whisperWords[0].start; + const speechEnd = whisperWords[whisperWords.length - 1].end; + const speechDur = speechEnd - speechStart; + if (speechDur <= 0) { + return syllables.map(s => ({ syl: s, start: speechStart, end: speechStart + 0.1 })); } - return result; + + // Суммарная длина всех слогов в символах + const totalChars = syllables.reduce((sum, s) => sum + s.length, 0); + + // Распределяем временной диапазон пропорционально длине слогов + let charOffset = 0; + return syllables.map(syl => { + const sylRatio = syl.length / totalChars; + const start = speechStart + (charOffset / totalChars) * speechDur; + const end = start + sylRatio * speechDur; + charOffset += syl.length; + return { syl, start, end }; + }); } // ---------- AudioContext для проигрывания слогов ---------- @@ -291,6 +258,29 @@ async function getAudioCtx() { let _currentBlob = null; let _currentSylBuf = null; let _currentSylMap = null; // результат mapSyllablesToTimestamps +let _speechOnset = 0; // реальное начало речи по амплитуде (сек) +let _speechEnd = 0; // реальный конец речи по амплитуде (сек) + +// Поиск реального начала/конца речи по амплитуде аудиобуфера +function findSpeechRange(buf) { + const data = buf.getChannelData(0); + const sr = buf.sampleRate; + // Порог: 3% от максимальной амплитуды + let maxAmp = 0; + for (let i = 0; i < data.length; i++) maxAmp = Math.max(maxAmp, Math.abs(data[i])); + const threshold = maxAmp * 0.03; + + let onset = 0, ending = buf.duration; + for (let i = 0; i < data.length; i++) { + if (Math.abs(data[i]) > threshold) { onset = i / sr; break; } + } + for (let i = data.length - 1; i >= 0; i--) { + if (Math.abs(data[i]) > threshold) { ending = i / sr; break; } + } + // Минимальная длительность речи + if (ending - onset < 0.1) { onset = 0; ending = buf.duration; } + return { onset, ending }; +} async function playSyllable(sylIdx, total) { if (!_currentBlob) return; @@ -299,18 +289,27 @@ async function playSyllable(sylIdx, total) { if (!_currentSylBuf) { const ab = await _currentBlob.arrayBuffer(); _currentSylBuf = await ctx.decodeAudioData(ab); + const range = findSpeechRange(_currentSylBuf); + _speechOnset = range.onset; + _speechEnd = range.ending; + console.log('[SYL] speechRange: ' + _speechOnset.toFixed(3) + '–' + _speechEnd.toFixed(3) + 's (total ' + _currentSylBuf.duration.toFixed(3) + 's)'); } let start, len; - // Используем точные таймстемпы Whisper если есть + // Используем точные таймстемпы Whisper + коррекция по амплитуде if (_currentSylMap && sylIdx < _currentSylMap.length) { const sm = _currentSylMap[sylIdx]; if (sm.start !== null && sm.end !== null) { - start = sm.start; + // Сдвигаем whisper-таймстемпы на реальное начало речи + const shift = _speechOnset - (_currentSylMap[0].start || 0); + start = sm.start + shift; len = sm.end - sm.start; - // Минимальная длительность 50ms чтобы слог был слышен - if (len < 0.05) len = 0.05; + // Минимальная длительность 80ms чтобы слог был отчётливо слышен + if (len < 0.08) { const mid = start + len/2; start = mid - 0.04; len = 0.08; } + // Не выходить за границы буфера + if (start < 0) start = 0; + if (start + len > _currentSylBuf.duration) len = _currentSylBuf.duration - start; } else { // Fallback: равномерное деление const dur = _currentSylBuf.duration; @@ -877,12 +876,23 @@ async function doCompare(blob, originalText, duration) { // Слоги — в отдельный 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('-'); @@ -915,7 +925,7 @@ async function doCompare(blob, originalText, duration) {