diff --git a/dist/index.html b/dist/index.html index 77f2d5b..814d365 100644 --- a/dist/index.html +++ b/dist/index.html @@ -201,10 +201,85 @@ function syllabifyIT(w) { return out.join('-'); } +function syllabifyIntoArray(text) { + const result = []; + for (const w of text.split(/\s+/)) { + for (const s of syllabifyIT(w).split('-')) result.push(s); + } + return result; +} + function syllabifyPhrase(text) { return text.split(/\s+/).map(syllabifyIT).join(' '); } +// Сопоставление слогов с временными метками Whisper +// whisperWords: [{word, start, end}, ...] +// originalText: исходный текст (что пользователь должен был сказать) +// Возвращает: [{syl, 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 }); + } + + // Сопоставляем слоги исходного текста с 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 }); + } + } + return result; +} + // ---------- AudioContext для проигрывания слогов ---------- let _audioCtx = null; async function getAudioCtx() { @@ -215,6 +290,7 @@ async function getAudioCtx() { let _currentBlob = null; let _currentSylBuf = null; +let _currentSylMap = null; // результат mapSyllablesToTimestamps async function playSyllable(sylIdx, total) { if (!_currentBlob) return; @@ -224,14 +300,38 @@ async function playSyllable(sylIdx, total) { const ab = await _currentBlob.arrayBuffer(); _currentSylBuf = await ctx.decodeAudioData(ab); } - const dur = _currentSylBuf.duration; - const start = (dur / total) * sylIdx; - const len = dur / total; + + let start, len; + + // Используем точные таймстемпы Whisper если есть + if (_currentSylMap && sylIdx < _currentSylMap.length) { + const sm = _currentSylMap[sylIdx]; + if (sm.start !== null && sm.end !== null) { + start = sm.start; + len = sm.end - sm.start; + // Минимальная длительность 50ms чтобы слог был слышен + if (len < 0.05) len = 0.05; + } else { + // Fallback: равномерное деление + const dur = _currentSylBuf.duration; + const firstWordStart = _currentSylMap.find(s => s.start !== null)?.start || 0; + const effectiveDur = dur - firstWordStart; + start = firstWordStart + (effectiveDur / total) * sylIdx; + len = effectiveDur / total; + } + } else { + // Без Whisper-данных: равномерное деление всей дорожки + const dur = _currentSylBuf.duration; + start = (dur / total) * sylIdx; + len = dur / total; + } + + console.log('[SYL] #' + sylIdx + '/' + total + ' start=' + start.toFixed(3) + ' len=' + len.toFixed(3)); const src = ctx.createBufferSource(); src.buffer = _currentSylBuf; src.connect(ctx.destination); src.start(0, start, len); - } catch(e) {} + } catch(e) { console.log('[SYL] ERR', e); } } // Проигрывание слога из истории (загрузка blob из IndexedDB) @@ -244,6 +344,7 @@ async function playHistorySyl(recId, sylIdx, total) { if (!rec?.blob) return; _currentBlob = rec.blob; _currentSylBuf = null; + _currentSylMap = null; await playSyllable(sylIdx, total); } catch(e) {} } @@ -280,7 +381,7 @@ async function blobToWav(blob) { } async function transcribe(blob) { - if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; + if (!GROQ_API_KEY) return { text: '', words: [], error: 'no_key' }; const tStart = performance.now(); try { const wavBlob = await blobToWav(blob); @@ -288,6 +389,8 @@ async function transcribe(blob) { fd.append('file', wavBlob, 'audio.wav'); fd.append('model', 'whisper-1'); fd.append('language', 'it'); + fd.append('response_format', 'verbose_json'); + fd.append('timestamp_granularities[]', 'word'); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 30000); const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', { @@ -300,11 +403,13 @@ async function transcribe(blob) { const raw = await r.text(); console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200)); let data; - try { data = JSON.parse(raw); } catch(pe) { return { text: '', error: 'parse:' + raw.slice(0,80) }; } - return { text: data.text || '', error: data.error?.message || data.error || null }; + try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; } + const words = (data.words || []).map(w => ({ word: w.word, start: w.start, end: w.end })); + console.log('[TRANSCRIBE] words:', words.length, words.map(w => w.start.toFixed(2)+'-'+w.end.toFixed(2)+' '+w.word).join(', ')); + return { text: data.text || '', words, error: data.error?.message || data.error || null }; } catch(e) { console.log('[TRANSCRIBE] ERR', e); - return { text: '', error: e.message }; + return { text: '', words: [], error: e.message }; } } @@ -772,6 +877,10 @@ async function doCompare(blob, originalText, duration) { // Слоги — в отдельный div, не в diff (diff перезаписывается) _currentBlob = blob; _currentSylBuf = null; + _currentSylMap = whisperResult.words && whisperResult.words.length > 0 + ? mapSyllablesToTimestamps(originalText, whisperResult.words) + : null; + console.log('[SYL] map:', _currentSylMap); const sylDiv = document.getElementById('syllables'); sylDiv.innerHTML = ''; const sy = syllabifyIT(originalText); @@ -806,7 +915,7 @@ async function doCompare(blob, originalText, duration) {