Files
lang/js/syllables.js
T

172 lines
6.5 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}, ...] для каждого слога
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) {}
}