v106: коррекция слогов по амплитуде аудио — находим реальное начало речи
This commit is contained in:
Vendored
+71
-61
@@ -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) {
|
||||
<script>
|
||||
// ---------- Глобальные переменные и инициализация ----------
|
||||
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
|
||||
const VERSION = 'v104';
|
||||
const VERSION = 'v106';
|
||||
|
||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||
|
||||
@@ -89,12 +89,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('-');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// ---------- Глобальные переменные и инициализация ----------
|
||||
const GROQ_API_KEY = '';
|
||||
const VERSION = 'v104';
|
||||
const VERSION = 'v106';
|
||||
|
||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||
|
||||
+59
-60
@@ -38,67 +38,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 для проигрывания слогов ----------
|
||||
@@ -112,6 +79,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;
|
||||
@@ -120,18 +110,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;
|
||||
|
||||
Reference in New Issue
Block a user