// ---------- Слоги ---------- 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}, ...] для каждого слога // Принцип: берём общий диапазон речи [первое_слово.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 })); } // Диапазон речи (от первого до последнего слова) 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 })); } // Суммарная длина всех слогов в символах 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 для проигрывания слогов ---------- 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 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; try { const ctx = await getAudioCtx(); 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 + коррекция по амплитуде if (_currentSylMap && sylIdx < _currentSylMap.length) { const sm = _currentSylMap[sylIdx]; if (sm.start !== null && sm.end !== null) { // Сдвигаем whisper-таймстемпы на реальное начало речи const shift = _speechOnset - (_currentSylMap[0].start || 0); start = sm.start + shift; len = sm.end - sm.start; // Минимальная длительность 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; 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) {} }