v104: точная привязка слогов к аудио через word timestamps Whisper
This commit is contained in:
+105
-4
@@ -22,10 +22,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() {
|
||||
@@ -36,6 +111,7 @@ async function getAudioCtx() {
|
||||
|
||||
let _currentBlob = null;
|
||||
let _currentSylBuf = null;
|
||||
let _currentSylMap = null; // результат mapSyllablesToTimestamps
|
||||
|
||||
async function playSyllable(sylIdx, total) {
|
||||
if (!_currentBlob) return;
|
||||
@@ -45,14 +121,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)
|
||||
@@ -65,6 +165,7 @@ async function playHistorySyl(recId, sylIdx, total) {
|
||||
if (!rec?.blob) return;
|
||||
_currentBlob = rec.blob;
|
||||
_currentSylBuf = null;
|
||||
_currentSylMap = null;
|
||||
await playSyllable(sylIdx, total);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user