171 lines
7.0 KiB
JavaScript
171 lines
7.0 KiB
JavaScript
// ---------- Слоги ----------
|
||
|
||
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) {}
|
||
}
|