Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
450e95eb19 | ||
|
|
415b27edc2 | ||
|
|
2d94980038 | ||
|
|
6fe6f77c07 | ||
|
|
7302d6ef62 | ||
|
|
3d6fb6107c | ||
|
|
eb22ad2074 | ||
|
|
d8b3643da3 |
@@ -9,7 +9,7 @@ css = open(os.path.join(BASE, 'css/style.css')).read()
|
|||||||
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
||||||
f'<style>\n{css}\n</style>')
|
f'<style>\n{css}\n</style>')
|
||||||
|
|
||||||
for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'audio.js', 'compare.js', 'main.js']:
|
for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'pronounce.js', 'audio.js', 'compare.js', 'main.js']:
|
||||||
js = open(os.path.join(BASE, 'js', jsf)).read()
|
js = open(os.path.join(BASE, 'js', jsf)).read()
|
||||||
html = html.replace(f'<script src="js/{jsf}"></script>',
|
html = html.replace(f'<script src="js/{jsf}"></script>',
|
||||||
f'<script>\n{js}\n</script>')
|
f'<script>\n{js}\n</script>')
|
||||||
|
|||||||
+5
-4
@@ -7,7 +7,7 @@ echo "=== build ==="
|
|||||||
~/lang/.venv/bin/python3 ~/lang/build.sh
|
~/lang/.venv/bin/python3 ~/lang/build.sh
|
||||||
|
|
||||||
echo "=== inject API key ==="
|
echo "=== inject API key ==="
|
||||||
KEY=$(cat ~/lang/token.txt)
|
KEY=$(cat ~/lang/token_ru.txt)
|
||||||
python3 -c "
|
python3 -c "
|
||||||
h = open('$HOME/lang/dist/index.html').read()
|
h = open('$HOME/lang/dist/index.html').read()
|
||||||
h = h.replace(\"const GROQ_API_KEY = '';\", \"const GROQ_API_KEY = '$KEY';\")
|
h = h.replace(\"const GROQ_API_KEY = '';\", \"const GROQ_API_KEY = '$KEY';\")
|
||||||
@@ -16,9 +16,10 @@ open('$HOME/lang/dist/index.html', 'w').write(h)
|
|||||||
echo " done"
|
echo " done"
|
||||||
|
|
||||||
echo "=== rsync -> VM ==="
|
echo "=== rsync -> VM ==="
|
||||||
rsync -az -e "$SSH" --exclude='.git' --exclude='.venv' --exclude='__pycache__' ~/lang/ ${VM}:/opt/lyngvo/
|
rsync -az -e "$SSH" /home/naeel/lang/dist/ ${VM}:/var/www/lyngvo/
|
||||||
|
|
||||||
echo "=== apply on VM ==="
|
echo "=== update server ==="
|
||||||
$SSH $VM 'cp /opt/lyngvo/dist/index.html /var/www/lyngvo/index.html && echo OK'
|
rsync -az -e "$SSH" /home/naeel/lang/server/ ${VM}:/opt/groq-proxy/
|
||||||
|
$SSH $VM 'systemctl restart groq-proxy && echo OK'
|
||||||
|
|
||||||
echo "=== https://lang.kube5s.ru ==="
|
echo "=== https://lang.kube5s.ru ==="
|
||||||
|
|||||||
Vendored
+288
-47
@@ -48,6 +48,7 @@ button:disabled { background:#ccc; cursor:not-allowed; }
|
|||||||
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="score"></div>
|
<div id="score"></div>
|
||||||
|
<div id="pronounce"></div>
|
||||||
<div id="diff"></div>
|
<div id="diff"></div>
|
||||||
<div id="syllables"></div>
|
<div id="syllables"></div>
|
||||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
||||||
@@ -201,10 +202,52 @@ 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}, ...] для каждого слога
|
||||||
|
// Принцип: берём общий диапазон речи [первое_слово.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 для проигрывания слогов ----------
|
// ---------- AudioContext для проигрывания слогов ----------
|
||||||
let _audioCtx = null;
|
let _audioCtx = null;
|
||||||
async function getAudioCtx() {
|
async function getAudioCtx() {
|
||||||
@@ -215,6 +258,30 @@ async function getAudioCtx() {
|
|||||||
|
|
||||||
let _currentBlob = null;
|
let _currentBlob = null;
|
||||||
let _currentSylBuf = 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) {
|
async function playSyllable(sylIdx, total) {
|
||||||
if (!_currentBlob) return;
|
if (!_currentBlob) return;
|
||||||
@@ -223,15 +290,48 @@ 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)');
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
// Сдвигаем 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();
|
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,55 +344,72 @@ 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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// ---------- Groq API: транскрипция (HTTP-чанки <10KB — обход DPI) ----------
|
// ---------- Groq API: транскрипция (прямой POST через ProxyAPI.ru) ----------
|
||||||
|
|
||||||
|
async function blobToWav(blob) {
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const ctx = new AudioContext({ sampleRate: 16000 });
|
||||||
|
const buf = await ctx.decodeAudioData(ab);
|
||||||
|
await ctx.close();
|
||||||
|
const mono = new Float32Array(buf.length);
|
||||||
|
for (let c = 0; c < buf.numberOfChannels; c++) {
|
||||||
|
const ch = buf.getChannelData(c);
|
||||||
|
for (let i = 0; i < buf.length; i++) mono[i] += ch[i];
|
||||||
|
}
|
||||||
|
if (buf.numberOfChannels > 1) for (let i = 0; i < mono.length; i++) mono[i] /= buf.numberOfChannels;
|
||||||
|
const wavBuf = new ArrayBuffer(44 + mono.length * 2);
|
||||||
|
const v = new DataView(wavBuf);
|
||||||
|
const wr = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
|
||||||
|
wr(0, 'RIFF'); v.setUint32(4, 36 + mono.length * 2, true);
|
||||||
|
wr(8, 'WAVE'); wr(12, 'fmt ');
|
||||||
|
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, 16000, true); v.setUint32(28, 32000, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
wr(36, 'data'); v.setUint32(40, mono.length * 2, true);
|
||||||
|
let off = 44;
|
||||||
|
for (let i = 0; i < mono.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, mono[i]));
|
||||||
|
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2;
|
||||||
|
}
|
||||||
|
return new Blob([wavBuf], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
const base64 = await new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
|
|
||||||
const CHUNK = 3000; // ~4KB base64 на чанк (POST <10KB — DPI не режет)
|
|
||||||
const total = Math.ceil(base64.length / CHUNK);
|
|
||||||
const mime = blob.type || 'audio/webm';
|
|
||||||
console.log('[CHUNK] blob=' + (blob.size/1024).toFixed(1) + 'KB b64=' + base64.length + ' chunks=' + total);
|
|
||||||
|
|
||||||
let sid = '';
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < base64.length; i += CHUNK) {
|
const wavBlob = await blobToWav(blob);
|
||||||
const idx = Math.floor(i / CHUNK);
|
const fd = new FormData();
|
||||||
const body = JSON.stringify({
|
fd.append('file', wavBlob, 'audio.wav');
|
||||||
idx, total,
|
fd.append('model', 'whisper-1');
|
||||||
chunk: base64.slice(i, i + CHUNK),
|
fd.append('language', 'it');
|
||||||
mime, token: GROQ_API_KEY,
|
fd.append('response_format', 'verbose_json');
|
||||||
sid
|
fd.append('timestamp_granularities[]', 'word');
|
||||||
});
|
const ctrl = new AbortController();
|
||||||
const r = await fetch('https://lang.kube5s.ru/openai/v1/transcribe', {
|
const timer = setTimeout(() => ctrl.abort(), 30000);
|
||||||
method: 'POST',
|
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
||||||
});
|
body: fd,
|
||||||
const data = await r.json();
|
signal: ctrl.signal
|
||||||
if (idx === total - 1) {
|
});
|
||||||
console.log('[CHUNK] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
clearTimeout(timer);
|
||||||
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
|
const raw = await r.text();
|
||||||
}
|
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
|
||||||
sid = data.sid || '';
|
let data;
|
||||||
}
|
try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; }
|
||||||
|
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('[CHUNK] ERR', e);
|
console.log('[TRANSCRIBE] ERR', e);
|
||||||
return { text: '', error: 'network' };
|
return { text: '', words: [], error: e.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +420,7 @@ async function translateToRussian(text) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'llama-3.3-70b-versatile',
|
model: 'gpt-4o',
|
||||||
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
||||||
max_tokens: 50,
|
max_tokens: 50,
|
||||||
temperature: 0
|
temperature: 0
|
||||||
@@ -316,6 +433,107 @@ async function translateToRussian(text) {
|
|||||||
} catch(e) { /* тихо */ }
|
} catch(e) { /* тихо */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Pronunciation Assessment ----------
|
||||||
|
|
||||||
|
async function assessPronunciation(expectedText, whisperResult) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/pronounce/assess', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected: expectedText,
|
||||||
|
whisper: whisperResult,
|
||||||
|
lang: 'it'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!r.ok) { console.log('[PRONOUNCE] HTTP', r.status); return null; }
|
||||||
|
const data = await r.json();
|
||||||
|
console.log('[PRONOUNCE] score=' + data.overall_score + ' ' + data.quality);
|
||||||
|
return data;
|
||||||
|
} catch(e) {
|
||||||
|
console.log('[PRONOUNCE] ERR', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPronunciation(assess) {
|
||||||
|
if (!assess) return '';
|
||||||
|
|
||||||
|
const s = assess.overall_score;
|
||||||
|
const emoji = s >= 90 ? '🟢' : s >= 70 ? '🟡' : s >= 50 ? '🟠' : '🔴';
|
||||||
|
|
||||||
|
let html = '<div style="margin:10px 0;padding:12px;background:#1a1a2e;border-radius:8px;color:#e0e0e0">';
|
||||||
|
|
||||||
|
// Score bar
|
||||||
|
const barColor = s >= 90 ? '#4caf50' : s >= 70 ? '#ff9800' : s >= 50 ? '#f44336' : '#9e9e9e';
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">';
|
||||||
|
html += '<span style="font-size:2em">' + emoji + '</span>';
|
||||||
|
html += '<div style="flex:1">';
|
||||||
|
html += '<div style="font-size:1.4em;font-weight:bold">Произношение: <span style="color:' + barColor + '">' + s + '%</span></div>';
|
||||||
|
html += '<div style="color:#aaa;font-size:0.9em">' + (assess.quality || '') + '</div>';
|
||||||
|
// Bar
|
||||||
|
html += '<div style="height:6px;background:#333;border-radius:3px;margin-top:6px">';
|
||||||
|
html += '<div style="width:' + s + '%;height:100%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div>';
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
// Details grid
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.85em">';
|
||||||
|
|
||||||
|
// Phonemes
|
||||||
|
if (assess.phoneme_comparison) {
|
||||||
|
const pc = assess.phoneme_comparison;
|
||||||
|
html += '<div><b>🔤 Фонемы:</b> ' + pc.accuracy + '%</div>';
|
||||||
|
html += '<div><b>✓ Совпадений:</b> ' + (pc.matches || 0) + '/' + (pc.total_phonemes || 0) + '</div>';
|
||||||
|
if (pc.errors && pc.errors.length > 0) {
|
||||||
|
const subs = pc.errors.filter(e => e.type === 'sub');
|
||||||
|
const dels = pc.errors.filter(e => e.type === 'del');
|
||||||
|
const inss = pc.errors.filter(e => e.type === 'ins');
|
||||||
|
html += '<div style="grid-column:1/-1">';
|
||||||
|
if (subs.length) html += '<span style="color:#ff9800">Замен: ' + subs.map(e => e.expected + '→' + e.actual).join(', ') + '</span> ';
|
||||||
|
if (dels.length) html += '<span style="color:#f44336">Пропущено: ' + dels.map(e => e.expected).join(', ') + '</span> ';
|
||||||
|
if (inss.length) html += '<span style="color:#9c27b0">Лишних: ' + inss.map(e => e.actual).join(', ') + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
if (assess.timing) {
|
||||||
|
const tm = assess.timing;
|
||||||
|
html += '<div><b>⏱ Ритм:</b> ' + (tm.rhythm_score || 0) + '%</div>';
|
||||||
|
html += '<div><b>📏 Длит-ть:</b> ' + (tm.total_duration || 0).toFixed(1) + 'с</div>';
|
||||||
|
if (tm.timing_quality) {
|
||||||
|
const tq = tm.timing_quality;
|
||||||
|
html += '<div style="grid-column:1/-1;color:' + (tq === 'good' ? '#4caf50' : tq === 'ok' ? '#ff9800' : '#f44336') + '">';
|
||||||
|
html += tq === 'good' ? '✅ Ритм ровный' : tq === 'ok' ? '⚠️ Ритм неровный' : '❌ Ритм сбит';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
if (assess.feedback) {
|
||||||
|
html += '<div style="margin-top:8px;padding:6px 10px;background:#2a2a3e;border-radius:4px;font-size:0.9em;color:#ccc">';
|
||||||
|
html += '💬 ' + assess.feedback;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phoneme detail
|
||||||
|
if (assess.expected && assess.expected.phonemes) {
|
||||||
|
html += '<div style="margin-top:6px;font-size:0.75em;color:#666">';
|
||||||
|
html += '🎯 Эталон: /' + assess.expected.phonemes.join(' ') + '/';
|
||||||
|
if (assess.actual && assess.actual.phonemes) {
|
||||||
|
html += ' | 🗣 Вы: /' + assess.actual.phonemes.join(' ') + '/';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||||
@@ -736,7 +954,7 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
}
|
}
|
||||||
clearInterval(timerId);
|
clearInterval(timerId);
|
||||||
|
|
||||||
let recHTML = '', diffHTML = '';
|
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||||
|
|
||||||
if (whisperResult.text) {
|
if (whisperResult.text) {
|
||||||
const _transcribed = whisperResult.text;
|
const _transcribed = whisperResult.text;
|
||||||
@@ -748,20 +966,43 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
renderDiff(words);
|
renderDiff(words);
|
||||||
|
|
||||||
|
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||||
|
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||||
|
if (assess) {
|
||||||
|
const el = document.getElementById('pronounce');
|
||||||
|
if (el) el.innerHTML = renderPronunciation(assess);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
recHTML = '⚠️ Не распознано';
|
recHTML = '⚠️ Не распознано';
|
||||||
diffHTML = '';
|
diffHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('score').innerHTML = recHTML;
|
document.getElementById('score').innerHTML = recHTML;
|
||||||
|
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||||
document.getElementById('diff').innerHTML = diffHTML;
|
document.getElementById('diff').innerHTML = diffHTML;
|
||||||
|
|
||||||
// Слоги — в отдельный 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
|
||||||
|
? mapSyllablesToTimestamps(originalText, whisperResult.words)
|
||||||
|
: null;
|
||||||
|
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('-');
|
||||||
@@ -793,8 +1034,8 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// ---------- Глобальные переменные и инициализация ----------
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
const GROQ_API_KEY = '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';
|
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
|
||||||
const VERSION = 'v89';
|
const VERSION = 'v107';
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Баг: gunicorn WORKER TIMEOUT → 500 при транскрипции
|
||||||
|
|
||||||
|
**Дата:** 2026-05-23
|
||||||
|
**Затронуто:** https://lang.kube5s.ru — кнопка «Сравнить» (запись → Whisper → транскрипция)
|
||||||
|
**Симптом у пользователя:** каждая попытка транскрибировать → 500 / зависание / страница перезагружается
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что происходило в логах
|
||||||
|
|
||||||
|
```
|
||||||
|
nginx access.log:
|
||||||
|
03:31:31 POST /openai/v1/transcribe 200 # чанк 0 — принят
|
||||||
|
03:31:31 POST /openai/v1/transcribe 200 # чанк 1 — принят
|
||||||
|
03:31:31 POST /openai/v1/transcribe 200 # чанк 2 — принят
|
||||||
|
03:31:36 POST /openai/v1/transcribe 500 # последний чанк — CRASH
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
journalctl -u groq-proxy:
|
||||||
|
[CRITICAL] WORKER TIMEOUT (pid:453467)
|
||||||
|
[ERROR] Error handling request POST /v1/transcribe
|
||||||
|
Traceback:
|
||||||
|
proxy.py line 29: data = request.get_json(force=True) or {}
|
||||||
|
werkzeug/wrappers/request.py: get_data() → stream.read()
|
||||||
|
gunicorn/http/body.py: reader.read(1024) → unreader.read()
|
||||||
|
gunicorn/http/unreader.py line 65: return self.sock.recv(self.mxchunk)
|
||||||
|
gunicorn/workers/base.py: handle_abort ← SIGABRT → sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Корневая причина
|
||||||
|
|
||||||
|
**`--timeout 120` в gunicorn** (дефолт).
|
||||||
|
|
||||||
|
Схема работы chunked-POST:
|
||||||
|
1. Браузер пишет JSON (<10 КБ) в HTTP тело
|
||||||
|
2. nginx пробрасывает с `proxy_request_buffering off` → gunicorn начинает читать тело из сокета
|
||||||
|
3. На **последнем чанке** gunicorn НЕ просто читает тело — он внутри обработчика делает HTTP-запрос к Groq API, который занимает **4–6 секунд** (Whisper large-v3)
|
||||||
|
4. gunicorn sync-воркер по умолчанию имеет **таймаут 120 секунд**...
|
||||||
|
|
||||||
|
Но это не 120 секунд задержки — проблема в другом:
|
||||||
|
`proxy_request_buffering off` означает, что gunicorn читает тело запроса напрямую из TCP-сокета. Пока браузер держит keep-alive соединение, `sock.recv()` может блокироваться. Gunicorn мастер-процесс тикает watchdog каждую секунду и если воркер не ответил за `timeout` секунд — убивает его через SIGABRT.
|
||||||
|
|
||||||
|
В нашем случае: nginx отправляет тело, gunicorn получает, но момент когда `stream.read()` завис + Groq API занимает несколько секунд суммарно превышало watchdog-проверку при пиковой нагрузке (или при медленном соединении из России).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что предложил ИИ-ассистент перед решением (ошибочная диагностика)
|
||||||
|
|
||||||
|
**Ошибочный диагноз** (DeepSeek / предыдущая сессия):
|
||||||
|
|
||||||
|
> "Проблема в том что `_sessions = {}` — in-memory dict. Когда gunicorn воркер падает из-за `proxy_request_buffering off` + client disconnect (SIGABRT), сессии теряются. Нужно перевести хранение чанков на файловую систему `/tmp/lyngvo-sessions/`."
|
||||||
|
|
||||||
|
**Почему диагноз был неверным:**
|
||||||
|
|
||||||
|
1. Падение воркера — WORKER TIMEOUT, не SIGABRT от disconnect
|
||||||
|
Трейсбек: `unreader.py → handle_abort` — это gunicorn мастер убивает воркер по таймауту, не клиент
|
||||||
|
2. Сессии терялись именно потому что воркер убивался по таймауту — у него `_sessions` в памяти. Это следствие, а не причина
|
||||||
|
3. File-based sessions решили бы симптом (404 после restart) но не основную причину (500 на финальном чанке)
|
||||||
|
4. При `--timeout 0` watchdog отключён → воркер живёт сколько нужно → Groq успевает ответить → сессии не нужны
|
||||||
|
|
||||||
|
**Правильный диагноз:**
|
||||||
|
gunicorn убивал воркер по таймауту 120с → in-memory сессии терялись → все последующие чанки получали 404
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Одна строка в systemd unit-файле:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- ExecStart=/usr/local/bin/gunicorn --workers 1 --bind 127.0.0.1:8765 --timeout 120 proxy:app
|
||||||
|
+ ExecStart=/usr/local/bin/gunicorn --workers 1 --bind 127.0.0.1:8765 --timeout 0 proxy:app
|
||||||
|
```
|
||||||
|
|
||||||
|
`--timeout 0` отключает watchdog gunicorn-мастера. Воркер живёт неограниченно долго, успевает дождаться ответа Groq.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i "s/--timeout 120/--timeout 0/" /etc/systemd/system/groq-proxy.service
|
||||||
|
systemctl daemon-reload && systemctl restart groq-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Почему `--timeout 0` безопасно в нашем случае
|
||||||
|
|
||||||
|
- 1 воркер, 1 запрос одновременно
|
||||||
|
- Groq API всегда отвечает (максимум ~10 сек для Whisper large-v3)
|
||||||
|
- Если Groq висит вечно — Flask-запрос всё равно завершится по `requests` timeout (можно добавить явный timeout в proxy.py на уровне requests)
|
||||||
|
- Зависший воркер не блокирует других: `Restart=always` + `RestartSec=3` в systemd
|
||||||
|
|
||||||
|
**Если добавить явный timeout для requests к Groq (дополнительная защита):**
|
||||||
|
```python
|
||||||
|
resp = requests.post(GROQ_URL, headers=headers, files=files, timeout=30)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хронология попыток
|
||||||
|
|
||||||
|
| Версия | Попытка | Результат |
|
||||||
|
|--------|---------|-----------|
|
||||||
|
| v86–v88 | WebSocket base64-чанки | Chrome подключается (101), но data-фреймы не проходят (DPI блокирует WS-фреймы с данными) |
|
||||||
|
| v89 | Chunked HTTP POST JSON (<10 КБ каждый) | Чанки доходят, но последний (Groq-вызов) → 500 |
|
||||||
|
| v89 + `--timeout 0` | **Работает** | Groq успевает ответить, сессия не теряется |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая архитектура (рабочая)
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (Россия)
|
||||||
|
│ base64 аудио → split на чанки по 3000 символов (≈2 КБ JSON каждый)
|
||||||
|
│ POST /openai/v1/transcribe {idx, total, chunk, mime, token, sid}
|
||||||
|
▼
|
||||||
|
nginx (lang.kube5s.ru)
|
||||||
|
│ proxy_pass http://127.0.0.1:8765/
|
||||||
|
│ proxy_request_buffering off ← тело идёт напрямую в gunicorn
|
||||||
|
▼
|
||||||
|
gunicorn --timeout 0 --workers 1
|
||||||
|
▼
|
||||||
|
proxy.py (_sessions in-memory dict, sid → список чанков)
|
||||||
|
│ если idx == total-1 → собрать base64 → multipart POST → Groq
|
||||||
|
▼
|
||||||
|
Groq Whisper large-v3 (Italian)
|
||||||
|
└─ возвращает текст → proxy → nginx → браузер
|
||||||
|
```
|
||||||
|
|
||||||
|
**Почему DPI пропускает JSON <10 КБ:**
|
||||||
|
Российский DPI блокирует multipart/form-data с бинарными данными (>~10 КБ) в direction Россия→Германия. JSON-текст <10 КБ — проходит.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связанные файлы
|
||||||
|
|
||||||
|
- `/opt/groq-proxy/proxy.py` — Flask-прокси, сборка чанков, вызов Groq
|
||||||
|
- `/etc/systemd/system/groq-proxy.service` — `--timeout 0`
|
||||||
|
- `/etc/nginx/conf.d/lang.kube5s.ru.conf` — `proxy_request_buffering off`
|
||||||
|
- `server/proxy.py` — локальная копия
|
||||||
|
- `js/transcribe.js` — клиентская логика чанков
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Whisper API — Распознавание речи (ProxyAPI.ru)
|
||||||
|
|
||||||
|
> Источники:
|
||||||
|
> - [ProxyAPI.ru — Распознавание речи OpenAI API](https://proxyapi.ru/docs/openai-speech-to-text)
|
||||||
|
> - Тестирование через `curl` с реальными запросами (2026-05-23)
|
||||||
|
> - [OpenAI Speech-to-Text Guide](https://platform.openai.com/docs/guides/speech-to-text) (официальный, заблокирован из РФ)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.proxyapi.ru/openai/v1/audio/transcriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
Авторизация: `Authorization: Bearer <API_KEY>`
|
||||||
|
|
||||||
|
Второй endpoint — перевод в английский:
|
||||||
|
```
|
||||||
|
POST https://api.proxyapi.ru/openai/v1/audio/translations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Модели
|
||||||
|
|
||||||
|
| Модель | Форматы ответа | Временные метки | Поток |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `whisper-1` | json, text, srt, verbose_json, vtt | ✅ word + segment | ❌ |
|
||||||
|
| `gpt-4o-transcribe` | json, text | ❌ | ✅ |
|
||||||
|
| `gpt-4o-mini-transcribe` | json, text | ❌ | ✅ |
|
||||||
|
|
||||||
|
**Вывод:** для максимальной детализации (сегменты, слова,置信度) — только `whisper-1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Параметры запроса
|
||||||
|
|
||||||
|
| Параметр | Тип | Обязательный | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `file` | file | ✅ | Аудиофайл (mp3, mp4, mpeg, mpga, m4a, wav, webm). Макс 25 МБ |
|
||||||
|
| `model` | string | ✅ | `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` |
|
||||||
|
| `language` | string | ❌ | ISO-639-1 код (`it`, `en`, `ru`, ...). Без него — автоопределение |
|
||||||
|
| `prompt` | string | ❌ | Текст-подсказка: термины, имена, аббревиатуры для улучшения точности |
|
||||||
|
| `temperature` | float | ❌ | 0–1. По умолчанию 0. Выше = разнообразнее, но больше ошибок |
|
||||||
|
| `response_format` | string | ❌ | `json` (по умолч.), `text`, `srt`, `verbose_json`, `vtt` |
|
||||||
|
| `timestamp_granularities` | string[] | ❌ | `["word"]`, `["segment"]`, `["word","segment"]`. Только для `whisper-1` |
|
||||||
|
|
||||||
|
### ⚠️ Важный нюанс (webm)
|
||||||
|
|
||||||
|
ProxyAPI.ru **заявляет** поддержку `webm`, но по факту ffmpeg на их стороне **не может определить длительность** webm/opus файлов из Chrome. Ошибка:
|
||||||
|
```json
|
||||||
|
{"detail": "Cannot extract audio duration. Invalid file or format."}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Решение:** конвертировать webm → WAV (16kHz, mono) в браузере через Web Audio API перед отправкой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Форматы ответа
|
||||||
|
|
||||||
|
### 4.1 `json` (по умолчанию)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "распознанный текст"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
С `usage` (зависит от модели):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Sottotitoli e revisione a cura di QTSS",
|
||||||
|
"usage": {
|
||||||
|
"type": "duration",
|
||||||
|
"seconds": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `text`
|
||||||
|
|
||||||
|
Чистый текст, без JSON:
|
||||||
|
```
|
||||||
|
Sottotitoli e revisione a cura di QTSS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `verbose_json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task": "transcribe",
|
||||||
|
"language": "italian",
|
||||||
|
"duration": 2.0,
|
||||||
|
"text": "Sottotitoli e revisione a cura di QTSS",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"seek": 0,
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 2.0,
|
||||||
|
"text": " Sottotitoli e revisione a cura di QTSS",
|
||||||
|
"tokens": [50364, 318, 1521, 310, 270, 9384, 308, 34218, 68, 257, 1262, 64, 1026, 1249, 7327, 50, 50464],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"avg_logprob": -0.2574056088924408,
|
||||||
|
"compression_ratio": 0.8260869383811951,
|
||||||
|
"no_speech_prob": 0.8321689367294312
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"type": "duration",
|
||||||
|
"seconds": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Поля сегмента:
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Номер сегмента (с 0) |
|
||||||
|
| `seek` | Смещение в секундах от начала файла |
|
||||||
|
| `start` | Начало сегмента (сек) |
|
||||||
|
| `end` | Конец сегмента (сек) |
|
||||||
|
| `text` | Текст сегмента |
|
||||||
|
| `tokens` | Идентификаторы токенов Whisper |
|
||||||
|
| `temperature` | Использованная температура |
|
||||||
|
| `avg_logprob` | Средняя лог-вероятность токенов (выше = увереннее). Можно использовать для оценки качества |
|
||||||
|
| `compression_ratio` | Степень сжатия текста относительно аудио |
|
||||||
|
| `no_speech_prob` | **Вероятность отсутствия речи** (0–1). Значения > 0.5 = вероятно тишина/шум |
|
||||||
|
|
||||||
|
### 4.4 `verbose_json` + `timestamp_granularities=["word"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task": "transcribe",
|
||||||
|
"language": "italian",
|
||||||
|
"duration": 2.0,
|
||||||
|
"text": "Sottotitoli e revisione a cura di QTSS",
|
||||||
|
"words": [
|
||||||
|
{ "word": "Sottotitoli", "start": 0.0, "end": 0.48 },
|
||||||
|
{ "word": "e", "start": 0.48, "end": 0.56 },
|
||||||
|
{ "word": "revisione", "start": 0.56, "end": 1.12 },
|
||||||
|
{ "word": "a", "start": 1.12, "end": 1.20 },
|
||||||
|
{ "word": "cura", "start": 1.20, "end": 1.44 },
|
||||||
|
{ "word": "di", "start": 1.44, "end": 1.52 },
|
||||||
|
{ "word": "QTSS", "start": 1.52, "end": 2.0 }
|
||||||
|
],
|
||||||
|
"usage": { "type": "duration", "seconds": 2 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ При запросе `words`, поле `segments` **не возвращается** (взаимоисключающие).
|
||||||
|
|
||||||
|
### 4.5 `srt` / `vtt`
|
||||||
|
|
||||||
|
Форматы субтитров с таймкодами. Пример SRT:
|
||||||
|
```
|
||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:02,000
|
||||||
|
Sottotitoli e revisione a cura di QTSS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Поддерживаемые языки
|
||||||
|
|
||||||
|
Полный список (из официальных доков):
|
||||||
|
|
||||||
|
Африкаанс, арабский, армянский, азербайджанский, белорусский, боснийский, болгарский,
|
||||||
|
каталанский, китайский, хорватский, чешский, датский, голландский, **английский**,
|
||||||
|
эстонский, финский, французский, галисийский, немецкий, греческий, иврит, хинди,
|
||||||
|
венгерский, исландский, индонезийский, **итальянский**, японский, каннада, казахский,
|
||||||
|
корейский, латышский, литовский, македонский, малайский, маратхи, маори, непальский,
|
||||||
|
норвежский, персидский, польский, португальский, румынский, **русский**, сербский,
|
||||||
|
словацкий, словенский, испанский, суахили, шведский, тагальский, тамильский, тайский,
|
||||||
|
турецкий, украинский, урду, вьетнамский, валлийский.
|
||||||
|
|
||||||
|
Коды ISO-639-1: `it` (итальянский), `en` (английский), `ru` (русский), `fr` (французский), etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Prompt — улучшение распознавания
|
||||||
|
|
||||||
|
Параметр `prompt` помогает Whisper правильно распознать специфические термины,
|
||||||
|
имена, аббревиатуры. Работает как контекстная подсказка.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "whisper-1",
|
||||||
|
"file": "@audio.wav",
|
||||||
|
"language": "it",
|
||||||
|
"prompt": "amore, cuore, spaghetti, Ferrari, Lamborghini, ciao, buongiorno"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Рекомендация:** передавать ожидаемое слово/фразу как prompt для повышения точности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Полезные поля для оценки качества
|
||||||
|
|
||||||
|
### `avg_logprob` (средняя лог-вероятность)
|
||||||
|
- Диапазон: примерно от -2.0 (плохо) до 0 (идеально)
|
||||||
|
- Можно использовать как confidence score
|
||||||
|
- Порог ~ -0.5 для приемлемого качества
|
||||||
|
|
||||||
|
### `no_speech_prob` (вероятность тишины)
|
||||||
|
- 0 = точно речь
|
||||||
|
- 1 = точно тишина/шум
|
||||||
|
- Порог > 0.5 = вероятно, сказать нечего
|
||||||
|
|
||||||
|
### `compression_ratio`
|
||||||
|
- Отношение длины текста к длине аудио
|
||||||
|
- Аномальные значения могут указывать на галлюцинации
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ограничения
|
||||||
|
|
||||||
|
- Макс. размер файла: **25 МБ**
|
||||||
|
- Перевод (`/translations`) — **только на английский**
|
||||||
|
- Временные метки — **только `whisper-1`**
|
||||||
|
- Поток (`stream=true`) — **недоступен для `whisper-1`**
|
||||||
|
- `webm` заявлен но **не работает** с ProxyAPI.ru (нужна конвертация в WAV/MP3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Цены (ProxyAPI.ru, май 2026)
|
||||||
|
|
||||||
|
| Модель | Цена за минуту |
|
||||||
|
|---|---|
|
||||||
|
| `whisper-1` | ~0.006 $ |
|
||||||
|
| `gpt-4o-transcribe` | ~0.006 $ |
|
||||||
|
| `gpt-4o-mini-transcribe` | ~0.003 $ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Пример использования в Lyngvo
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// transcribe.js — актуальная версия (v103)
|
||||||
|
async function blobToWav(blob) {
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const ctx = new AudioContext({ sampleRate: 16000 });
|
||||||
|
const buf = await ctx.decodeAudioData(ab);
|
||||||
|
await ctx.close();
|
||||||
|
// ... конвертация в WAV 16kHz mono ...
|
||||||
|
return new Blob([wavBuf], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribe(blob) {
|
||||||
|
const wavBlob = await blobToWav(blob);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', wavBlob, 'audio.wav');
|
||||||
|
fd.append('model', 'whisper-1');
|
||||||
|
fd.append('language', 'it');
|
||||||
|
// По желанию — prompt для улучшения точности
|
||||||
|
// fd.append('prompt', 'amore, ciao, buongiorno');
|
||||||
|
|
||||||
|
const r = await fetch(
|
||||||
|
'https://api.proxyapi.ru/openai/v1/audio/transcriptions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${API_KEY}` },
|
||||||
|
body: fd
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return await r.json();
|
||||||
|
// → { text: "...", usage: { type: "duration", seconds: N } }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="score"></div>
|
<div id="score"></div>
|
||||||
|
<div id="pronounce"></div>
|
||||||
<div id="diff"></div>
|
<div id="diff"></div>
|
||||||
<div id="syllables"></div>
|
<div id="syllables"></div>
|
||||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
<script src="js/storage.js"></script>
|
<script src="js/storage.js"></script>
|
||||||
<script src="js/syllables.js"></script>
|
<script src="js/syllables.js"></script>
|
||||||
<script src="js/transcribe.js"></script>
|
<script src="js/transcribe.js"></script>
|
||||||
|
<script src="js/pronounce.js"></script>
|
||||||
<script src="js/audio.js"></script>
|
<script src="js/audio.js"></script>
|
||||||
<script src="js/compare.js"></script>
|
<script src="js/compare.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
|||||||
+25
-2
@@ -65,7 +65,7 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
}
|
}
|
||||||
clearInterval(timerId);
|
clearInterval(timerId);
|
||||||
|
|
||||||
let recHTML = '', diffHTML = '';
|
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||||
|
|
||||||
if (whisperResult.text) {
|
if (whisperResult.text) {
|
||||||
const _transcribed = whisperResult.text;
|
const _transcribed = whisperResult.text;
|
||||||
@@ -77,20 +77,43 @@ async function doCompare(blob, originalText, duration) {
|
|||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
renderDiff(words);
|
renderDiff(words);
|
||||||
|
|
||||||
|
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||||
|
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||||
|
if (assess) {
|
||||||
|
const el = document.getElementById('pronounce');
|
||||||
|
if (el) el.innerHTML = renderPronunciation(assess);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
recHTML = '⚠️ Не распознано';
|
recHTML = '⚠️ Не распознано';
|
||||||
diffHTML = '';
|
diffHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('score').innerHTML = recHTML;
|
document.getElementById('score').innerHTML = recHTML;
|
||||||
|
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||||
document.getElementById('diff').innerHTML = diffHTML;
|
document.getElementById('diff').innerHTML = diffHTML;
|
||||||
|
|
||||||
// Слоги — в отдельный 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
|
||||||
|
? mapSyllablesToTimestamps(originalText, whisperResult.words)
|
||||||
|
: null;
|
||||||
|
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
@@ -1,6 +1,6 @@
|
|||||||
// ---------- Глобальные переменные и инициализация ----------
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
const GROQ_API_KEY = '';
|
const GROQ_API_KEY = '';
|
||||||
const VERSION = 'v89';
|
const VERSION = 'v107';
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// ---------- Pronunciation Assessment ----------
|
||||||
|
|
||||||
|
async function assessPronunciation(expectedText, whisperResult) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/pronounce/assess', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected: expectedText,
|
||||||
|
whisper: whisperResult,
|
||||||
|
lang: 'it'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!r.ok) { console.log('[PRONOUNCE] HTTP', r.status); return null; }
|
||||||
|
const data = await r.json();
|
||||||
|
console.log('[PRONOUNCE] score=' + data.overall_score + ' ' + data.quality);
|
||||||
|
return data;
|
||||||
|
} catch(e) {
|
||||||
|
console.log('[PRONOUNCE] ERR', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPronunciation(assess) {
|
||||||
|
if (!assess) return '';
|
||||||
|
|
||||||
|
const s = assess.overall_score;
|
||||||
|
const emoji = s >= 90 ? '🟢' : s >= 70 ? '🟡' : s >= 50 ? '🟠' : '🔴';
|
||||||
|
|
||||||
|
let html = '<div style="margin:10px 0;padding:12px;background:#1a1a2e;border-radius:8px;color:#e0e0e0">';
|
||||||
|
|
||||||
|
// Score bar
|
||||||
|
const barColor = s >= 90 ? '#4caf50' : s >= 70 ? '#ff9800' : s >= 50 ? '#f44336' : '#9e9e9e';
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">';
|
||||||
|
html += '<span style="font-size:2em">' + emoji + '</span>';
|
||||||
|
html += '<div style="flex:1">';
|
||||||
|
html += '<div style="font-size:1.4em;font-weight:bold">Произношение: <span style="color:' + barColor + '">' + s + '%</span></div>';
|
||||||
|
html += '<div style="color:#aaa;font-size:0.9em">' + (assess.quality || '') + '</div>';
|
||||||
|
// Bar
|
||||||
|
html += '<div style="height:6px;background:#333;border-radius:3px;margin-top:6px">';
|
||||||
|
html += '<div style="width:' + s + '%;height:100%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div>';
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
// Details grid
|
||||||
|
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.85em">';
|
||||||
|
|
||||||
|
// Phonemes
|
||||||
|
if (assess.phoneme_comparison) {
|
||||||
|
const pc = assess.phoneme_comparison;
|
||||||
|
html += '<div><b>🔤 Фонемы:</b> ' + pc.accuracy + '%</div>';
|
||||||
|
html += '<div><b>✓ Совпадений:</b> ' + (pc.matches || 0) + '/' + (pc.total_phonemes || 0) + '</div>';
|
||||||
|
if (pc.errors && pc.errors.length > 0) {
|
||||||
|
const subs = pc.errors.filter(e => e.type === 'sub');
|
||||||
|
const dels = pc.errors.filter(e => e.type === 'del');
|
||||||
|
const inss = pc.errors.filter(e => e.type === 'ins');
|
||||||
|
html += '<div style="grid-column:1/-1">';
|
||||||
|
if (subs.length) html += '<span style="color:#ff9800">Замен: ' + subs.map(e => e.expected + '→' + e.actual).join(', ') + '</span> ';
|
||||||
|
if (dels.length) html += '<span style="color:#f44336">Пропущено: ' + dels.map(e => e.expected).join(', ') + '</span> ';
|
||||||
|
if (inss.length) html += '<span style="color:#9c27b0">Лишних: ' + inss.map(e => e.actual).join(', ') + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
if (assess.timing) {
|
||||||
|
const tm = assess.timing;
|
||||||
|
html += '<div><b>⏱ Ритм:</b> ' + (tm.rhythm_score || 0) + '%</div>';
|
||||||
|
html += '<div><b>📏 Длит-ть:</b> ' + (tm.total_duration || 0).toFixed(1) + 'с</div>';
|
||||||
|
if (tm.timing_quality) {
|
||||||
|
const tq = tm.timing_quality;
|
||||||
|
html += '<div style="grid-column:1/-1;color:' + (tq === 'good' ? '#4caf50' : tq === 'ok' ? '#ff9800' : '#f44336') + '">';
|
||||||
|
html += tq === 'good' ? '✅ Ритм ровный' : tq === 'ok' ? '⚠️ Ритм неровный' : '❌ Ритм сбит';
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
if (assess.feedback) {
|
||||||
|
html += '<div style="margin-top:8px;padding:6px 10px;background:#2a2a3e;border-radius:4px;font-size:0.9em;color:#ccc">';
|
||||||
|
html += '💬 ' + assess.feedback;
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phoneme detail
|
||||||
|
if (assess.expected && assess.expected.phonemes) {
|
||||||
|
html += '<div style="margin-top:6px;font-size:0.75em;color:#666">';
|
||||||
|
html += '🎯 Эталон: /' + assess.expected.phonemes.join(' ') + '/';
|
||||||
|
if (assess.actual && assess.actual.phonemes) {
|
||||||
|
html += ' | 🗣 Вы: /' + assess.actual.phonemes.join(' ') + '/';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
+104
-4
@@ -22,10 +22,52 @@ 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}, ...] для каждого слога
|
||||||
|
// Принцип: берём общий диапазон речи [первое_слово.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 для проигрывания слогов ----------
|
// ---------- AudioContext для проигрывания слогов ----------
|
||||||
let _audioCtx = null;
|
let _audioCtx = null;
|
||||||
async function getAudioCtx() {
|
async function getAudioCtx() {
|
||||||
@@ -36,6 +78,30 @@ async function getAudioCtx() {
|
|||||||
|
|
||||||
let _currentBlob = null;
|
let _currentBlob = null;
|
||||||
let _currentSylBuf = 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) {
|
async function playSyllable(sylIdx, total) {
|
||||||
if (!_currentBlob) return;
|
if (!_currentBlob) return;
|
||||||
@@ -44,15 +110,48 @@ 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)');
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
// Сдвигаем 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();
|
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 +164,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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-39
@@ -1,46 +1,62 @@
|
|||||||
// ---------- Groq API: транскрипция (HTTP-чанки <10KB — обход DPI) ----------
|
// ---------- Groq API: транскрипция (прямой POST через ProxyAPI.ru) ----------
|
||||||
|
|
||||||
|
async function blobToWav(blob) {
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const ctx = new AudioContext({ sampleRate: 16000 });
|
||||||
|
const buf = await ctx.decodeAudioData(ab);
|
||||||
|
await ctx.close();
|
||||||
|
const mono = new Float32Array(buf.length);
|
||||||
|
for (let c = 0; c < buf.numberOfChannels; c++) {
|
||||||
|
const ch = buf.getChannelData(c);
|
||||||
|
for (let i = 0; i < buf.length; i++) mono[i] += ch[i];
|
||||||
|
}
|
||||||
|
if (buf.numberOfChannels > 1) for (let i = 0; i < mono.length; i++) mono[i] /= buf.numberOfChannels;
|
||||||
|
const wavBuf = new ArrayBuffer(44 + mono.length * 2);
|
||||||
|
const v = new DataView(wavBuf);
|
||||||
|
const wr = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
|
||||||
|
wr(0, 'RIFF'); v.setUint32(4, 36 + mono.length * 2, true);
|
||||||
|
wr(8, 'WAVE'); wr(12, 'fmt ');
|
||||||
|
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, 16000, true); v.setUint32(28, 32000, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
wr(36, 'data'); v.setUint32(40, mono.length * 2, true);
|
||||||
|
let off = 44;
|
||||||
|
for (let i = 0; i < mono.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, mono[i]));
|
||||||
|
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2;
|
||||||
|
}
|
||||||
|
return new Blob([wavBuf], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
const base64 = await new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
|
|
||||||
const CHUNK = 3000; // ~4KB base64 на чанк (POST <10KB — DPI не режет)
|
|
||||||
const total = Math.ceil(base64.length / CHUNK);
|
|
||||||
const mime = blob.type || 'audio/webm';
|
|
||||||
console.log('[CHUNK] blob=' + (blob.size/1024).toFixed(1) + 'KB b64=' + base64.length + ' chunks=' + total);
|
|
||||||
|
|
||||||
let sid = '';
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < base64.length; i += CHUNK) {
|
const wavBlob = await blobToWav(blob);
|
||||||
const idx = Math.floor(i / CHUNK);
|
const fd = new FormData();
|
||||||
const body = JSON.stringify({
|
fd.append('file', wavBlob, 'audio.wav');
|
||||||
idx, total,
|
fd.append('model', 'whisper-1');
|
||||||
chunk: base64.slice(i, i + CHUNK),
|
fd.append('language', 'it');
|
||||||
mime, token: GROQ_API_KEY,
|
fd.append('response_format', 'verbose_json');
|
||||||
sid
|
fd.append('timestamp_granularities[]', 'word');
|
||||||
});
|
const ctrl = new AbortController();
|
||||||
const r = await fetch('https://lang.kube5s.ru/openai/v1/transcribe', {
|
const timer = setTimeout(() => ctrl.abort(), 30000);
|
||||||
method: 'POST',
|
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
||||||
});
|
body: fd,
|
||||||
const data = await r.json();
|
signal: ctrl.signal
|
||||||
if (idx === total - 1) {
|
});
|
||||||
console.log('[CHUNK] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
clearTimeout(timer);
|
||||||
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
|
const raw = await r.text();
|
||||||
}
|
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
|
||||||
sid = data.sid || '';
|
let data;
|
||||||
}
|
try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; }
|
||||||
|
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('[CHUNK] ERR', e);
|
console.log('[TRANSCRIBE] ERR', e);
|
||||||
return { text: '', error: 'network' };
|
return { text: '', words: [], error: e.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +67,7 @@ async function translateToRussian(text) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'llama-3.3-70b-versatile',
|
model: 'gpt-4o',
|
||||||
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
||||||
max_tokens: 50,
|
max_tokens: 50,
|
||||||
temperature: 0
|
temperature: 0
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
expires -1;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pronunciation assessment engine — поверх Whisper API."""
|
||||||
|
|
||||||
|
from phonemizer import phonemize
|
||||||
|
import json, re, math
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phoneme conversion ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def text_to_phonemes(text, lang="it"):
|
||||||
|
"""text → list of phoneme strings."""
|
||||||
|
raw = phonemize(text, language=lang, backend="espeak", strip=True,
|
||||||
|
preserve_punctuation=False, with_stress=False)
|
||||||
|
# espeak returns space-separated phonemes, but some are multi-char
|
||||||
|
# Parse carefully: split on spaces, merge tied phonemes
|
||||||
|
tokens = raw.split()
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def phonemes_to_syllables(phonemes):
|
||||||
|
"""Group phonemes into rough syllables (vowel = nucleus)."""
|
||||||
|
vowels = set("aeɛiouɔɑɒʌəɨʉɯʊɤeøɘɵɐœɶʏɪɞ")
|
||||||
|
syllables = []
|
||||||
|
cur = []
|
||||||
|
for p in phonemes:
|
||||||
|
cur.append(p)
|
||||||
|
# Any vowel-ish character means syllable nucleus
|
||||||
|
if any(c in vowels for c in p):
|
||||||
|
pass # keep collecting consonants after vowel in same syllable
|
||||||
|
# Simple heuristic: two consonants in a row → split before second
|
||||||
|
# Simpler approach: just split on vowel positions
|
||||||
|
result = []
|
||||||
|
buf = []
|
||||||
|
for p in phonemes:
|
||||||
|
buf.append(p)
|
||||||
|
has_vowel = any(c in vowels for c in p)
|
||||||
|
if has_vowel:
|
||||||
|
result.append("-".join(buf))
|
||||||
|
buf = []
|
||||||
|
if buf:
|
||||||
|
if result:
|
||||||
|
result[-1] = result[-1] + "-" + "-".join(buf)
|
||||||
|
else:
|
||||||
|
result.append("-".join(buf))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expected pronunciation model ────────────────────────────────────
|
||||||
|
|
||||||
|
def build_expected(text, lang="it"):
|
||||||
|
"""Build expected pronunciation model for given text."""
|
||||||
|
phonemes = text_to_phonemes(text, lang)
|
||||||
|
syllables = phonemes_to_syllables(phonemes)
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"language": lang,
|
||||||
|
"phonemes": phonemes,
|
||||||
|
"syllables": syllables,
|
||||||
|
"phoneme_count": len(phonemes),
|
||||||
|
"syllable_count": len(syllables),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timing analysis ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def analyze_timing(words, expected_duration=None):
|
||||||
|
"""Analyze word timing from Whisper output."""
|
||||||
|
if not words:
|
||||||
|
return {"error": "no words"}
|
||||||
|
|
||||||
|
times = []
|
||||||
|
for w in words:
|
||||||
|
dur = w.get("end", 0) - w.get("start", 0)
|
||||||
|
times.append({
|
||||||
|
"word": w.get("word", ""),
|
||||||
|
"start": w.get("start", 0),
|
||||||
|
"end": w.get("end", 0),
|
||||||
|
"duration": dur,
|
||||||
|
})
|
||||||
|
|
||||||
|
total = times[-1]["end"] - times[0]["start"] if times else 0
|
||||||
|
avg_speed = sum(t["duration"] for t in times) / len(times) if times else 0
|
||||||
|
|
||||||
|
# Rhythm score: lower std deviation = more natural rhythm
|
||||||
|
durs = [t["duration"] for t in times]
|
||||||
|
mean_dur = sum(durs) / len(durs) if durs else 1
|
||||||
|
variance = sum((d - mean_dur)**2 for d in durs) / len(durs) if durs else 0
|
||||||
|
rhythm_score = max(0, 100 - math.sqrt(variance) * 50)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"words": times,
|
||||||
|
"total_duration": total,
|
||||||
|
"avg_word_duration": avg_speed,
|
||||||
|
"rhythm_score": round(rhythm_score, 1),
|
||||||
|
"timing_quality": "good" if rhythm_score > 70 else "ok" if rhythm_score > 40 else "poor",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phoneme comparison ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def levenshtein_ops(a, b):
|
||||||
|
"""Levenshtein with backtrace — returns list of (op, char_a, char_b)."""
|
||||||
|
m, n = len(a), len(b)
|
||||||
|
dp = [[0]*(n+1) for _ in range(m+1)]
|
||||||
|
for i in range(m+1): dp[i][0] = i
|
||||||
|
for j in range(n+1): dp[0][j] = j
|
||||||
|
for i in range(1, m+1):
|
||||||
|
for j in range(1, n+1):
|
||||||
|
cost = 0 if a[i-1] == b[j-1] else 1
|
||||||
|
dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
|
||||||
|
|
||||||
|
# Backtrace
|
||||||
|
ops = []
|
||||||
|
i, j = m, n
|
||||||
|
while i > 0 or j > 0:
|
||||||
|
if i > 0 and j > 0 and a[i-1] == b[j-1]:
|
||||||
|
ops.append(("match", a[i-1], b[j-1]))
|
||||||
|
i -= 1; j -= 1
|
||||||
|
elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1:
|
||||||
|
ops.append(("sub", a[i-1], b[j-1]))
|
||||||
|
i -= 1; j -= 1
|
||||||
|
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
|
||||||
|
ops.append(("del", a[i-1], ""))
|
||||||
|
i -= 1
|
||||||
|
else:
|
||||||
|
ops.append(("ins", "", b[j-1]))
|
||||||
|
j -= 1
|
||||||
|
ops.reverse()
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
def compare_phonemes(expected_phonemes, actual_phonemes):
|
||||||
|
"""Compare expected vs actual phonemes."""
|
||||||
|
ops = levenshtein_ops(expected_phonemes, actual_phonemes)
|
||||||
|
|
||||||
|
matches = sum(1 for o in ops if o[0] == "match")
|
||||||
|
substitutions = sum(1 for o in ops if o[0] == "sub")
|
||||||
|
deletions = sum(1 for o in ops if o[0] == "del")
|
||||||
|
insertions = sum(1 for o in ops if o[0] == "ins")
|
||||||
|
total = len(ops)
|
||||||
|
|
||||||
|
accuracy = round(matches / max(total, 1) * 100, 1)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for op, expected, actual in ops:
|
||||||
|
if op != "match":
|
||||||
|
errors.append({"type": op, "expected": expected, "actual": actual})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"accuracy": accuracy,
|
||||||
|
"total_phonemes": total,
|
||||||
|
"matches": matches,
|
||||||
|
"substitutions": substitutions,
|
||||||
|
"deletions": deletions,
|
||||||
|
"insertions": insertions,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Italian-specific confusion penalties ────────────────────────────
|
||||||
|
|
||||||
|
ITALIAN_CONFUSION = {
|
||||||
|
# Russian → Italian common errors
|
||||||
|
("r", "ɾ"): 0.5, # rolled R
|
||||||
|
("l", "ʎ"): 0.7, # gli sound
|
||||||
|
("n", "ɲ"): 0.7, # gn sound
|
||||||
|
("e", "ɛ"): 0.3, # open e
|
||||||
|
("e", "e"): 0.0, # same
|
||||||
|
("o", "ɔ"): 0.3, # open o
|
||||||
|
# Double consonants (gemination)
|
||||||
|
("t", "tt"): 0.6,
|
||||||
|
("l", "ll"): 0.6,
|
||||||
|
("n", "nn"): 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_confusion_penalty(errors):
|
||||||
|
"""Apply language-specific penalties to phoneme errors."""
|
||||||
|
weighted = 0
|
||||||
|
total = len(errors) if errors else 1
|
||||||
|
for err in errors:
|
||||||
|
key = (err.get("expected", ""), err.get("actual", ""))
|
||||||
|
penalty = ITALIAN_CONFUSION.get(key, 1.0)
|
||||||
|
weighted += penalty
|
||||||
|
return round(weighted / total, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scoring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compute_score(text_comparison, phoneme_comparison, timing_analysis, confusion_penalty):
|
||||||
|
"""Compute overall pronunciation score 0-100."""
|
||||||
|
# Text accuracy weight: 30%
|
||||||
|
text_score = text_comparison.get("score", 0)
|
||||||
|
|
||||||
|
# Phoneme accuracy weight: 35%
|
||||||
|
phoneme_score = phoneme_comparison.get("accuracy", 0)
|
||||||
|
|
||||||
|
# Timing/rhythm weight: 20%
|
||||||
|
timing_score = timing_analysis.get("rhythm_score", 0)
|
||||||
|
|
||||||
|
# Confusion penalty weight: 15% (inverted — lower penalty = higher score)
|
||||||
|
penalty_score = max(0, 100 - confusion_penalty * 100)
|
||||||
|
|
||||||
|
overall = text_score * 0.30 + phoneme_score * 0.35 + timing_score * 0.20 + penalty_score * 0.15
|
||||||
|
return round(overall, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def quality_label(score):
|
||||||
|
if score >= 90: return "Отлично! 🇮🇹"
|
||||||
|
if score >= 75: return "Хорошо 👍"
|
||||||
|
if score >= 60: return "Неплохо 🙂"
|
||||||
|
if score >= 40: return "Нужна практика 📚"
|
||||||
|
return "Попробуй ещё раз 💪"
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI Feedback ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_feedback(assessment):
|
||||||
|
"""Generate human-readable feedback from assessment data."""
|
||||||
|
parts = []
|
||||||
|
score = assessment.get("overall_score", 0)
|
||||||
|
|
||||||
|
if score >= 90:
|
||||||
|
parts.append("🎉 Отличное произношение!")
|
||||||
|
elif score >= 75:
|
||||||
|
parts.append("👍 Хорошее произношение, есть небольшие недочёты.")
|
||||||
|
elif score >= 60:
|
||||||
|
parts.append("📚 Неплохо, но нужно поработать над звуками.")
|
||||||
|
else:
|
||||||
|
parts.append("💪 Требуется практика произношения.")
|
||||||
|
|
||||||
|
# Phoneme errors
|
||||||
|
errors = assessment.get("phoneme_comparison", {}).get("errors", [])
|
||||||
|
sub_errors = [e for e in errors if e["type"] == "sub"]
|
||||||
|
if sub_errors:
|
||||||
|
sample = sub_errors[:3]
|
||||||
|
parts.append("Звуки для отработки: " + ", ".join(
|
||||||
|
f"{e['expected']}→{e['actual']}" for e in sample
|
||||||
|
))
|
||||||
|
|
||||||
|
del_errors = [e for e in errors if e["type"] == "del"]
|
||||||
|
if del_errors:
|
||||||
|
parts.append(f"Пропущено звуков: {len(del_errors)}.")
|
||||||
|
|
||||||
|
ins_errors = [e for e in errors if e["type"] == "ins"]
|
||||||
|
if ins_errors:
|
||||||
|
parts.append(f"Лишних звуков: {len(ins_errors)}.")
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
timing = assessment.get("timing", {})
|
||||||
|
if timing.get("timing_quality") == "poor":
|
||||||
|
parts.append("⏱ Ритм неравномерный — попробуй говорить плавнее.")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Full assessment ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def assess(expected_text, whisper_result, lang="it"):
|
||||||
|
"""
|
||||||
|
Full pronunciation assessment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expected_text: "buona sera"
|
||||||
|
whisper_result: {"text": "...", "words": [{word, start, end}, ...]}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full assessment dict with scores, phonemes, errors, feedback.
|
||||||
|
"""
|
||||||
|
# 1. Build expected model
|
||||||
|
expected = build_expected(expected_text, lang)
|
||||||
|
|
||||||
|
# 2. Build actual model from Whisper output
|
||||||
|
actual_text = whisper_result.get("text", "").strip()
|
||||||
|
actual_words = whisper_result.get("words", [])
|
||||||
|
actual_phonemes = text_to_phonemes(actual_text, lang) if actual_text else []
|
||||||
|
|
||||||
|
# 3. Text-level comparison (Levenshtein from frontend, or compute here)
|
||||||
|
# Use simple word accuracy
|
||||||
|
expected_words_norm = re.sub(r"[^\w\s]", "", expected_text.lower()).split()
|
||||||
|
actual_words_norm = re.sub(r"[^\w\s]", "", actual_text.lower()).split() if actual_text else []
|
||||||
|
word_matches = sum(1 for e, a in zip(expected_words_norm, actual_words_norm) if e == a)
|
||||||
|
text_score = round(word_matches / max(len(expected_words_norm), 1) * 100, 1)
|
||||||
|
|
||||||
|
text_comparison = {
|
||||||
|
"expected": expected_text,
|
||||||
|
"actual": actual_text,
|
||||||
|
"expected_words": expected_words_norm,
|
||||||
|
"actual_words": actual_words_norm,
|
||||||
|
"word_matches": word_matches,
|
||||||
|
"total_words": len(expected_words_norm),
|
||||||
|
"score": text_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Phoneme comparison
|
||||||
|
phoneme_comparison = compare_phonemes(expected["phonemes"], actual_phonemes)
|
||||||
|
|
||||||
|
# 5. Timing analysis
|
||||||
|
timing = analyze_timing(actual_words)
|
||||||
|
|
||||||
|
# 6. Confusion penalty
|
||||||
|
confusion_penalty = apply_confusion_penalty(phoneme_comparison.get("errors", []))
|
||||||
|
|
||||||
|
# 7. Overall score
|
||||||
|
overall = compute_score(text_comparison, phoneme_comparison, timing, confusion_penalty)
|
||||||
|
|
||||||
|
assessment = {
|
||||||
|
"overall_score": overall,
|
||||||
|
"quality": quality_label(overall),
|
||||||
|
"expected": expected,
|
||||||
|
"actual": {
|
||||||
|
"text": actual_text,
|
||||||
|
"phonemes": actual_phonemes,
|
||||||
|
"words": actual_words,
|
||||||
|
},
|
||||||
|
"text_comparison": text_comparison,
|
||||||
|
"phoneme_comparison": phoneme_comparison,
|
||||||
|
"timing": timing,
|
||||||
|
"confusion_penalty": confusion_penalty,
|
||||||
|
"language": lang,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 8. Feedback
|
||||||
|
assessment["feedback"] = generate_feedback(assessment)
|
||||||
|
|
||||||
|
return assessment
|
||||||
|
|
||||||
|
|
||||||
|
# ── Flask endpoint ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
@app.route("/pronounce/assess", methods=["POST"])
|
||||||
|
def pronounce_assess():
|
||||||
|
try:
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
expected = data.get("expected", "").strip()
|
||||||
|
whisper_result = data.get("whisper", {})
|
||||||
|
lang = data.get("lang", "it")
|
||||||
|
|
||||||
|
if not expected:
|
||||||
|
return {"error": "no expected text"}, 400
|
||||||
|
if not whisper_result.get("text"):
|
||||||
|
return {"error": "no whisper result"}, 400
|
||||||
|
|
||||||
|
result = assess(expected, whisper_result, lang)
|
||||||
|
result["_version"] = "1.0.0"
|
||||||
|
|
||||||
|
print(f"[PRONOUNCE] \"{expected}\" → score={result['overall_score']} "
|
||||||
|
f"phonemes={result['phoneme_comparison']['accuracy']}% "
|
||||||
|
f"rhythm={result['timing']['rhythm_score']}", flush=True)
|
||||||
|
|
||||||
|
resp = app.make_response((json.dumps(result, ensure_ascii=False), 200))
|
||||||
|
resp.headers.update({
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PRONOUNCE] ERROR: {e}", flush=True)
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
@app.route("/pronounce/health", methods=["GET"])
|
||||||
|
def pronounce_health():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"phonemizer": "espeak",
|
||||||
|
"languages": ["it", "en", "fr", "de", "es", "ru"],
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
+13
-67
@@ -1,12 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from flask import Flask, request, Response
|
from flask import Flask, request, Response
|
||||||
import requests
|
import requests
|
||||||
import base64
|
import json
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
GROQ_BASE = "https://api.groq.com"
|
GROQ_BASE = "https://api.proxyapi.ru/openai"
|
||||||
|
|
||||||
CORS = {
|
CORS = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
@@ -14,72 +12,16 @@ CORS = {
|
|||||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Чанковая сборка аудио (DPI bypass — каждый POST <10KB)
|
# Импорт pronounce после создания app
|
||||||
_sessions = {} # session_id -> {chunks, mime, total, token, expires}
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from pronounce import register_routes
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
@app.route("/<path:path>", methods=["OPTIONS"])
|
@app.route("/<path:path>", methods=["OPTIONS"])
|
||||||
def options(path):
|
def options(path):
|
||||||
return Response(status=204, headers=CORS)
|
return Response(status=204, headers=CORS)
|
||||||
|
|
||||||
@app.route("/v1/transcribe", methods=["POST", "OPTIONS"])
|
|
||||||
def transcribe_chunked():
|
|
||||||
if request.method == "OPTIONS":
|
|
||||||
return Response(status=204, headers=CORS)
|
|
||||||
|
|
||||||
data = request.get_json(force=True) or {}
|
|
||||||
idx = data.get("idx", 0)
|
|
||||||
total = data.get("total", 1)
|
|
||||||
chunk = data.get("chunk", "")
|
|
||||||
mime = data.get("mime", "audio/webm")
|
|
||||||
token = data.get("token", "")
|
|
||||||
sid = data.get("sid", "")
|
|
||||||
|
|
||||||
# Очистка старых сессий
|
|
||||||
now = time.time()
|
|
||||||
for k in list(_sessions.keys()):
|
|
||||||
if _sessions[k]["expires"] < now:
|
|
||||||
del _sessions[k]
|
|
||||||
|
|
||||||
if not sid:
|
|
||||||
sid = uuid.uuid4().hex[:12]
|
|
||||||
_sessions[sid] = {"chunks": {}, "mime": mime, "total": total, "token": token, "expires": now + 120}
|
|
||||||
else:
|
|
||||||
s = _sessions.get(sid)
|
|
||||||
if not s:
|
|
||||||
return Response('{"error":"session not found"}', status=404, headers=CORS, content_type="application/json")
|
|
||||||
|
|
||||||
_sessions[sid]["chunks"][idx] = chunk
|
|
||||||
_sessions[sid]["expires"] = now + 120
|
|
||||||
|
|
||||||
if len(_sessions[sid]["chunks"]) >= total:
|
|
||||||
s = _sessions.pop(sid)
|
|
||||||
audio_b64 = "".join(s["chunks"][i] for i in sorted(s["chunks"]))
|
|
||||||
audio_data = base64.b64decode(audio_b64)
|
|
||||||
|
|
||||||
ext = "webm"
|
|
||||||
if "ogg" in s["mime"]: ext = "ogg"
|
|
||||||
elif "mp4" in s["mime"] or "aac" in s["mime"]: ext = "mp4"
|
|
||||||
elif "wav" in s["mime"]: ext = "wav"
|
|
||||||
|
|
||||||
boundary = "----ChunkedBoundary" + uuid.uuid4().hex[:16]
|
|
||||||
payload = b"".join([
|
|
||||||
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.{ext}"\r\nContent-Type: {s["mime"]}\r\n\r\n'.encode(),
|
|
||||||
audio_data,
|
|
||||||
f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3'.encode(),
|
|
||||||
f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nit'.encode(),
|
|
||||||
f'\r\n--{boundary}--\r\n'.encode(),
|
|
||||||
])
|
|
||||||
|
|
||||||
r = requests.post(f"{GROQ_BASE}/openai/v1/audio/transcriptions",
|
|
||||||
headers={"Authorization": f"Bearer {s['token']}", "Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
||||||
data=payload, timeout=60)
|
|
||||||
result = r.json()
|
|
||||||
out = dict(CORS)
|
|
||||||
out["Content-Type"] = "application/json"
|
|
||||||
return Response(r.text, status=r.status_code, headers=out)
|
|
||||||
else:
|
|
||||||
return Response(f'{{"ok":true,"sid":"{sid}"}}', status=200, headers=CORS, content_type="application/json")
|
|
||||||
|
|
||||||
@app.route("/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
@app.route("/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
def proxy(subpath):
|
def proxy(subpath):
|
||||||
url = f"{GROQ_BASE}/{subpath}"
|
url = f"{GROQ_BASE}/{subpath}"
|
||||||
@@ -88,11 +30,15 @@ def proxy(subpath):
|
|||||||
hdrs["Authorization"] = request.headers["Authorization"]
|
hdrs["Authorization"] = request.headers["Authorization"]
|
||||||
if request.headers.get("Content-Type"):
|
if request.headers.get("Content-Type"):
|
||||||
hdrs["Content-Type"] = request.headers["Content-Type"]
|
hdrs["Content-Type"] = request.headers["Content-Type"]
|
||||||
|
body = request.get_data()
|
||||||
|
print(f"[PROXY] {request.method} {subpath} body={len(body)}b ct={hdrs.get('Content-Type','')[:50]}", flush=True)
|
||||||
r = requests.request(request.method, url, headers=hdrs,
|
r = requests.request(request.method, url, headers=hdrs,
|
||||||
data=request.get_data(), timeout=60, stream=True)
|
data=body, timeout=60, stream=True)
|
||||||
out = dict(CORS)
|
out = dict(CORS)
|
||||||
out["Content-Type"] = r.headers.get("Content-Type", "application/json")
|
out["Content-Type"] = r.headers.get("Content-Type", "application/json")
|
||||||
return Response(r.iter_content(8192), status=r.status_code, headers=out)
|
raw = r.content
|
||||||
|
print(f"[PROXY] ← {r.status_code} {raw[:200]}", flush=True)
|
||||||
|
return Response(raw, status=r.status_code, headers=out)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="127.0.0.1", port=8765)
|
app.run(host="127.0.0.1", port=8765)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL
|
||||||
Reference in New Issue
Block a user