Files
lang/js/syllables.js

171 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Слоги ----------
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) {}
}