v104: точная привязка слогов к аудио через word timestamps Whisper

This commit is contained in:
“Naeel”
2026-05-23 14:39:54 +04:00
parent 7302d6ef62
commit 6fe6f77c07
5 changed files with 236 additions and 18 deletions
+118 -9
View File
@@ -201,10 +201,85 @@ function syllabifyIT(w) {
return out.join('-'); 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) { function syllabifyPhrase(text) {
return text.split(/\s+/).map(syllabifyIT).join(' '); 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 для проигрывания слогов ---------- // ---------- AudioContext для проигрывания слогов ----------
let _audioCtx = null; let _audioCtx = null;
async function getAudioCtx() { async function getAudioCtx() {
@@ -215,6 +290,7 @@ async function getAudioCtx() {
let _currentBlob = null; let _currentBlob = null;
let _currentSylBuf = null; let _currentSylBuf = null;
let _currentSylMap = null; // результат mapSyllablesToTimestamps
async function playSyllable(sylIdx, total) { async function playSyllable(sylIdx, total) {
if (!_currentBlob) return; if (!_currentBlob) return;
@@ -224,14 +300,38 @@ async function playSyllable(sylIdx, total) {
const ab = await _currentBlob.arrayBuffer(); const ab = await _currentBlob.arrayBuffer();
_currentSylBuf = await ctx.decodeAudioData(ab); _currentSylBuf = await ctx.decodeAudioData(ab);
} }
const dur = _currentSylBuf.duration;
const start = (dur / total) * sylIdx; let start, len;
const len = dur / total;
// Используем точные таймстемпы 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(); const src = ctx.createBufferSource();
src.buffer = _currentSylBuf; src.buffer = _currentSylBuf;
src.connect(ctx.destination); src.connect(ctx.destination);
src.start(0, start, len); src.start(0, start, len);
} catch(e) {} } catch(e) { console.log('[SYL] ERR', e); }
} }
// Проигрывание слога из истории (загрузка blob из IndexedDB) // Проигрывание слога из истории (загрузка blob из IndexedDB)
@@ -244,6 +344,7 @@ async function playHistorySyl(recId, sylIdx, total) {
if (!rec?.blob) return; if (!rec?.blob) return;
_currentBlob = rec.blob; _currentBlob = rec.blob;
_currentSylBuf = null; _currentSylBuf = null;
_currentSylMap = null;
await playSyllable(sylIdx, total); await playSyllable(sylIdx, total);
} catch(e) {} } catch(e) {}
} }
@@ -280,7 +381,7 @@ async function blobToWav(blob) {
} }
async function transcribe(blob) { async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; if (!GROQ_API_KEY) return { text: '', words: [], error: 'no_key' };
const tStart = performance.now(); const tStart = performance.now();
try { try {
const wavBlob = await blobToWav(blob); const wavBlob = await blobToWav(blob);
@@ -288,6 +389,8 @@ async function transcribe(blob) {
fd.append('file', wavBlob, 'audio.wav'); fd.append('file', wavBlob, 'audio.wav');
fd.append('model', 'whisper-1'); fd.append('model', 'whisper-1');
fd.append('language', 'it'); fd.append('language', 'it');
fd.append('response_format', 'verbose_json');
fd.append('timestamp_granularities[]', 'word');
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000); const timer = setTimeout(() => ctrl.abort(), 30000);
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', { const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
@@ -300,11 +403,13 @@ async function transcribe(blob) {
const raw = await r.text(); const raw = await r.text();
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200)); console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
let data; let data;
try { data = JSON.parse(raw); } catch(pe) { return { text: '', error: 'parse:' + raw.slice(0,80) }; } try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; }
return { text: data.text || '', error: data.error?.message || data.error || null }; const words = (data.words || []).map(w => ({ word: w.word, start: w.start, end: w.end }));
console.log('[TRANSCRIBE] words:', words.length, words.map(w => w.start.toFixed(2)+'-'+w.end.toFixed(2)+' '+w.word).join(', '));
return { text: data.text || '', words, error: data.error?.message || data.error || null };
} catch(e) { } catch(e) {
console.log('[TRANSCRIBE] ERR', e); console.log('[TRANSCRIBE] ERR', e);
return { text: '', error: e.message }; return { text: '', words: [], error: e.message };
} }
} }
@@ -772,6 +877,10 @@ async function doCompare(blob, originalText, duration) {
// Слоги — в отдельный div, не в diff (diff перезаписывается) // Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob; _currentBlob = blob;
_currentSylBuf = null; _currentSylBuf = null;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null;
console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables'); const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = ''; sylDiv.innerHTML = '';
const sy = syllabifyIT(originalText); const sy = syllabifyIT(originalText);
@@ -806,7 +915,7 @@ async function doCompare(blob, originalText, duration) {
<script> <script>
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL'; const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
const VERSION = 'v103'; const VERSION = 'v104';
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;
+4
View File
@@ -89,6 +89,10 @@ async function doCompare(blob, originalText, duration) {
// Слоги — в отдельный div, не в diff (diff перезаписывается) // Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob; _currentBlob = blob;
_currentSylBuf = null; _currentSylBuf = null;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null;
console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables'); const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = ''; sylDiv.innerHTML = '';
const sy = syllabifyIT(originalText); const sy = syllabifyIT(originalText);
+1 -1
View File
@@ -1,6 +1,6 @@
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = ''; const GROQ_API_KEY = '';
const VERSION = 'v103'; const VERSION = 'v104';
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;
+105 -4
View File
@@ -22,10 +22,85 @@ function syllabifyIT(w) {
return out.join('-'); 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) { function syllabifyPhrase(text) {
return text.split(/\s+/).map(syllabifyIT).join(' '); 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 для проигрывания слогов ---------- // ---------- AudioContext для проигрывания слогов ----------
let _audioCtx = null; let _audioCtx = null;
async function getAudioCtx() { async function getAudioCtx() {
@@ -36,6 +111,7 @@ async function getAudioCtx() {
let _currentBlob = null; let _currentBlob = null;
let _currentSylBuf = null; let _currentSylBuf = null;
let _currentSylMap = null; // результат mapSyllablesToTimestamps
async function playSyllable(sylIdx, total) { async function playSyllable(sylIdx, total) {
if (!_currentBlob) return; if (!_currentBlob) return;
@@ -45,14 +121,38 @@ async function playSyllable(sylIdx, total) {
const ab = await _currentBlob.arrayBuffer(); const ab = await _currentBlob.arrayBuffer();
_currentSylBuf = await ctx.decodeAudioData(ab); _currentSylBuf = await ctx.decodeAudioData(ab);
} }
const dur = _currentSylBuf.duration;
const start = (dur / total) * sylIdx; let start, len;
const len = dur / total;
// Используем точные таймстемпы 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(); const src = ctx.createBufferSource();
src.buffer = _currentSylBuf; src.buffer = _currentSylBuf;
src.connect(ctx.destination); src.connect(ctx.destination);
src.start(0, start, len); src.start(0, start, len);
} catch(e) {} } catch(e) { console.log('[SYL] ERR', e); }
} }
// Проигрывание слога из истории (загрузка blob из IndexedDB) // Проигрывание слога из истории (загрузка blob из IndexedDB)
@@ -65,6 +165,7 @@ async function playHistorySyl(recId, sylIdx, total) {
if (!rec?.blob) return; if (!rec?.blob) return;
_currentBlob = rec.blob; _currentBlob = rec.blob;
_currentSylBuf = null; _currentSylBuf = null;
_currentSylMap = null;
await playSyllable(sylIdx, total); await playSyllable(sylIdx, total);
} catch(e) {} } catch(e) {}
} }
+8 -4
View File
@@ -28,7 +28,7 @@ async function blobToWav(blob) {
} }
async function transcribe(blob) { async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; if (!GROQ_API_KEY) return { text: '', words: [], error: 'no_key' };
const tStart = performance.now(); const tStart = performance.now();
try { try {
const wavBlob = await blobToWav(blob); const wavBlob = await blobToWav(blob);
@@ -36,6 +36,8 @@ async function transcribe(blob) {
fd.append('file', wavBlob, 'audio.wav'); fd.append('file', wavBlob, 'audio.wav');
fd.append('model', 'whisper-1'); fd.append('model', 'whisper-1');
fd.append('language', 'it'); fd.append('language', 'it');
fd.append('response_format', 'verbose_json');
fd.append('timestamp_granularities[]', 'word');
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000); const timer = setTimeout(() => ctrl.abort(), 30000);
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', { const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
@@ -48,11 +50,13 @@ async function transcribe(blob) {
const raw = await r.text(); const raw = await r.text();
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200)); console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
let data; let data;
try { data = JSON.parse(raw); } catch(pe) { return { text: '', error: 'parse:' + raw.slice(0,80) }; } try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; }
return { text: data.text || '', error: data.error?.message || data.error || null }; const words = (data.words || []).map(w => ({ word: w.word, start: w.start, end: w.end }));
console.log('[TRANSCRIBE] words:', words.length, words.map(w => w.start.toFixed(2)+'-'+w.end.toFixed(2)+' '+w.word).join(', '));
return { text: data.text || '', words, error: data.error?.message || data.error || null };
} catch(e) { } catch(e) {
console.log('[TRANSCRIBE] ERR', e); console.log('[TRANSCRIBE] ERR', e);
return { text: '', error: e.message }; return { text: '', words: [], error: e.message };
} }
} }