diff --git a/dist/index.html b/dist/index.html
index 814d365..b44494d 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -217,67 +217,34 @@ function syllabifyPhrase(text) {
// 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 }));
-
- // Собираем полный текст из 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 });
+ if (!whisperWords || whisperWords.length === 0) {
+ return syllables.map(s => ({ syl: s, start: null, end: null }));
}
- // Сопоставляем слоги исходного текста с 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 });
- }
+ // Диапазон речи (от первого до последнего слова)
+ 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 }));
}
- return result;
+
+ // Суммарная длина всех слогов в символах
+ 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 для проигрывания слогов ----------
@@ -291,6 +258,29 @@ async function getAudioCtx() {
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;
@@ -299,18 +289,27 @@ async function playSyllable(sylIdx, total) {
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 если есть
+ // Используем точные таймстемпы Whisper + коррекция по амплитуде
if (_currentSylMap && sylIdx < _currentSylMap.length) {
const sm = _currentSylMap[sylIdx];
if (sm.start !== null && sm.end !== null) {
- start = sm.start;
+ // Сдвигаем whisper-таймстемпы на реальное начало речи
+ const shift = _speechOnset - (_currentSylMap[0].start || 0);
+ start = sm.start + shift;
len = sm.end - sm.start;
- // Минимальная длительность 50ms чтобы слог был слышен
- if (len < 0.05) len = 0.05;
+ // Минимальная длительность 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;
@@ -877,12 +876,23 @@ async function doCompare(blob, originalText, duration) {
// Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob;
_currentSylBuf = null;
+ _speechOnset = 0;
+ _speechEnd = 0;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null;
console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = '';
+ // Показываем отладочную инфу: диапазон речи и первый/последний слог
+ if (_currentSylMap && _currentSylMap.length > 0) {
+ const first = _currentSylMap[0], last = _currentSylMap[_currentSylMap.length - 1];
+ const dbg = document.createElement('div');
+ dbg.style.cssText = 'font-size:0.7em;color:#aaa;margin-top:2px';
+ dbg.textContent = '⏱ речь: ' + (first.start||0).toFixed(2) + '–' + (last.end||0).toFixed(2) + 'с | ' +
+ _currentSylMap.map(s => s.syl + '[' + (s.start||0).toFixed(2) + ']').join(' ');
+ sylDiv.appendChild(dbg);
+ }
const sy = syllabifyIT(originalText);
if (sy && sy.includes('-')) {
const syls = sy.split('-');
@@ -915,7 +925,7 @@ async function doCompare(blob, originalText, duration) {