v106: коррекция слогов по амплитуде аудио — находим реальное начало речи

This commit is contained in:
“Naeel”
2026-05-23 17:14:45 +04:00
parent 6fe6f77c07
commit 2d94980038
4 changed files with 142 additions and 122 deletions
+71 -61
View File
@@ -217,67 +217,34 @@ function syllabifyPhrase(text) {
// whisperWords: [{word, start, end}, ...] // whisperWords: [{word, start, end}, ...]
// originalText: исходный текст (что пользователь должен был сказать) // originalText: исходный текст (что пользователь должен был сказать)
// Возвращает: [{syl, start, end}, ...] для каждого слога // Возвращает: [{syl, start, end}, ...] для каждого слога
// Принцип: берём общий диапазон речи [первое_слово.start, последнее_слово.end]
// и пропорционально делим его по длине слогов исходного текста
function mapSyllablesToTimestamps(originalText, whisperWords) { function mapSyllablesToTimestamps(originalText, whisperWords) {
const syllables = syllabifyIntoArray(originalText); const syllables = syllabifyIntoArray(originalText);
if (!whisperWords || whisperWords.length === 0) return syllables.map(s => ({ syl: s, start: null, end: null })); 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 = []; const speechStart = whisperWords[0].start;
let wsIdx = 0, wsSylIdx = 0; const speechEnd = whisperWords[whisperWords.length - 1].end;
const speechDur = speechEnd - speechStart;
for (const syl of syllables) { if (speechDur <= 0) {
const normSyl = norm(syl); return syllables.map(s => ({ syl: s, start: speechStart, end: speechStart + 0.1 }));
// Ищем этот слог в 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;
// Суммарная длина всех слогов в символах
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 для проигрывания слогов ---------- // ---------- AudioContext для проигрывания слогов ----------
@@ -291,6 +258,29 @@ async function getAudioCtx() {
let _currentBlob = null; let _currentBlob = null;
let _currentSylBuf = null; let _currentSylBuf = null;
let _currentSylMap = null; // результат mapSyllablesToTimestamps 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) { async function playSyllable(sylIdx, total) {
if (!_currentBlob) return; if (!_currentBlob) return;
@@ -299,18 +289,27 @@ async function playSyllable(sylIdx, total) {
if (!_currentSylBuf) { if (!_currentSylBuf) {
const ab = await _currentBlob.arrayBuffer(); const ab = await _currentBlob.arrayBuffer();
_currentSylBuf = await ctx.decodeAudioData(ab); _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; let start, len;
// Используем точные таймстемпы Whisper если есть // Используем точные таймстемпы Whisper + коррекция по амплитуде
if (_currentSylMap && sylIdx < _currentSylMap.length) { if (_currentSylMap && sylIdx < _currentSylMap.length) {
const sm = _currentSylMap[sylIdx]; const sm = _currentSylMap[sylIdx];
if (sm.start !== null && sm.end !== null) { 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; len = sm.end - sm.start;
// Минимальная длительность 50ms чтобы слог был слышен // Минимальная длительность 80ms чтобы слог был отчётливо слышен
if (len < 0.05) len = 0.05; 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 { } else {
// Fallback: равномерное деление // Fallback: равномерное деление
const dur = _currentSylBuf.duration; const dur = _currentSylBuf.duration;
@@ -877,12 +876,23 @@ async function doCompare(blob, originalText, duration) {
// Слоги — в отдельный div, не в diff (diff перезаписывается) // Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob; _currentBlob = blob;
_currentSylBuf = null; _currentSylBuf = null;
_speechOnset = 0;
_speechEnd = 0;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0 _currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words) ? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null; : null;
console.log('[SYL] map:', _currentSylMap); console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables'); const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = ''; 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); const sy = syllabifyIT(originalText);
if (sy && sy.includes('-')) { if (sy && sy.includes('-')) {
const syls = sy.split('-'); const syls = sy.split('-');
@@ -915,7 +925,7 @@ async function doCompare(blob, originalText, duration) {
<script> <script>
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL'; const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
const VERSION = 'v104'; const VERSION = 'v106';
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
+11
View File
@@ -89,12 +89,23 @@ async function doCompare(blob, originalText, duration) {
// Слоги — в отдельный div, не в diff (diff перезаписывается) // Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob; _currentBlob = blob;
_currentSylBuf = null; _currentSylBuf = null;
_speechOnset = 0;
_speechEnd = 0;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0 _currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words) ? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null; : null;
console.log('[SYL] map:', _currentSylMap); console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables'); const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = ''; 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); const sy = syllabifyIT(originalText);
if (sy && sy.includes('-')) { if (sy && sy.includes('-')) {
const syls = sy.split('-'); const syls = sy.split('-');
+1 -1
View File
@@ -1,6 +1,6 @@
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = ''; const GROQ_API_KEY = '';
const VERSION = 'v104'; const VERSION = 'v106';
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
+59 -60
View File
@@ -38,67 +38,34 @@ function syllabifyPhrase(text) {
// whisperWords: [{word, start, end}, ...] // whisperWords: [{word, start, end}, ...]
// originalText: исходный текст (что пользователь должен был сказать) // originalText: исходный текст (что пользователь должен был сказать)
// Возвращает: [{syl, start, end}, ...] для каждого слога // Возвращает: [{syl, start, end}, ...] для каждого слога
// Принцип: берём общий диапазон речи [первое_слово.start, последнее_слово.end]
// и пропорционально делим его по длине слогов исходного текста
function mapSyllablesToTimestamps(originalText, whisperWords) { function mapSyllablesToTimestamps(originalText, whisperWords) {
const syllables = syllabifyIntoArray(originalText); const syllables = syllabifyIntoArray(originalText);
if (!whisperWords || whisperWords.length === 0) return syllables.map(s => ({ syl: s, start: null, end: null })); 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 = []; const speechStart = whisperWords[0].start;
let wsIdx = 0, wsSylIdx = 0; const speechEnd = whisperWords[whisperWords.length - 1].end;
const speechDur = speechEnd - speechStart;
for (const syl of syllables) { if (speechDur <= 0) {
const normSyl = norm(syl); return syllables.map(s => ({ syl: s, start: speechStart, end: speechStart + 0.1 }));
// Ищем этот слог в 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;
// Суммарная длина всех слогов в символах
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 для проигрывания слогов ---------- // ---------- AudioContext для проигрывания слогов ----------
@@ -112,6 +79,29 @@ async function getAudioCtx() {
let _currentBlob = null; let _currentBlob = null;
let _currentSylBuf = null; let _currentSylBuf = null;
let _currentSylMap = null; // результат mapSyllablesToTimestamps 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) { async function playSyllable(sylIdx, total) {
if (!_currentBlob) return; if (!_currentBlob) return;
@@ -120,18 +110,27 @@ async function playSyllable(sylIdx, total) {
if (!_currentSylBuf) { if (!_currentSylBuf) {
const ab = await _currentBlob.arrayBuffer(); const ab = await _currentBlob.arrayBuffer();
_currentSylBuf = await ctx.decodeAudioData(ab); _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; let start, len;
// Используем точные таймстемпы Whisper если есть // Используем точные таймстемпы Whisper + коррекция по амплитуде
if (_currentSylMap && sylIdx < _currentSylMap.length) { if (_currentSylMap && sylIdx < _currentSylMap.length) {
const sm = _currentSylMap[sylIdx]; const sm = _currentSylMap[sylIdx];
if (sm.start !== null && sm.end !== null) { 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; len = sm.end - sm.start;
// Минимальная длительность 50ms чтобы слог был слышен // Минимальная длительность 80ms чтобы слог был отчётливо слышен
if (len < 0.05) len = 0.05; 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 { } else {
// Fallback: равномерное деление // Fallback: равномерное деление
const dur = _currentSylBuf.duration; const dur = _currentSylBuf.duration;