// ---------- Слоги ---------- function syllabifyIT(w) { const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase()); const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s); let out = [], syl = '', i = 0; while (i < w.length) { syl += w[i]; if (isV(w[i])) { let j = i + 1, cons = ''; while (j < w.length && !isV(w[j])) cons += w[j++]; if (j < w.length && cons.length > 0) { const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2); syl += cons.slice(0, k); out.push(syl); syl = ''; i += k; } } i++; } if (syl) out.push(syl); 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() { if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_audioCtx.state === 'suspended') await _audioCtx.resume(); return _audioCtx; } let _currentBlob = null; let _currentSylBuf = null; let _currentSylMap = null; // результат mapSyllablesToTimestamps async function playSyllable(sylIdx, total) { if (!_currentBlob) return; try { const ctx = await getAudioCtx(); if (!_currentSylBuf) { const ab = await _currentBlob.arrayBuffer(); _currentSylBuf = await ctx.decodeAudioData(ab); } 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) { console.log('[SYL] ERR', e); } } // Проигрывание слога из истории (загрузка blob из IndexedDB) async function playHistorySyl(recId, sylIdx, total) { try { const db = await openRecDB(); const tx = db.transaction('recs', 'readonly'); const store = tx.objectStore('recs'); const rec = await new Promise(r => { const req = store.get(recId); req.onsuccess = () => r(req.result); }); if (!rec?.blob) return; _currentBlob = rec.blob; _currentSylBuf = null; _currentSylMap = null; await playSyllable(sylIdx, total); } catch(e) {} }