v39: Whisper proxy via Flask+gunicorn, blobToWav revert, syllabification, isAnalyzing flag, timing logs
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
# Lyngvo — архитектура и инфраструктура
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Одностраничное веб-приложение (SPA) для тренировки итальянского произношения.
|
||||||
|
URL: `https://capire.kube5s.ru`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | Single HTML file (`index.html`), vanilla JS, Web Audio API |
|
||||||
|
| Хостинг | Kubernetes (kube5s), namespace `default`, deploy `lyngvo` |
|
||||||
|
| Конфиг | ConfigMap `lyngvo-html` (index.html) + `lyngvo-nginx` (nginx.conf) |
|
||||||
|
| Ingress | `capire.kube5s.ru` |
|
||||||
|
| AI: распознавание речи | Groq Whisper (`whisper-large-v3`) |
|
||||||
|
| AI: перевод | Groq LLaMA (`llama-3.3-70b-versatile`) |
|
||||||
|
| TTS | Groq TTS (через тот же прокси) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Groq API прокси
|
||||||
|
|
||||||
|
Groq API заблокирован в России. Все запросы идут через немецкий сервер.
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (Россия)
|
||||||
|
→ HTTPS → proxy.kube5s.ru (Германия, 95.179.252.111)
|
||||||
|
nginx (TLS termination, port 443)
|
||||||
|
→ localhost:8765 (Python HTTP сервер)
|
||||||
|
→ HTTPS → api.groq.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Компоненты на немецком сервере
|
||||||
|
|
||||||
|
| Файл/сервис | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `/opt/groq-proxy/proxy.py` | Python HTTP сервер, универсальный форвардер |
|
||||||
|
| `groq-proxy.service` (systemd) | Автозапуск proxy.py |
|
||||||
|
| `/etc/nginx/conf.d/groq-proxy.conf` | TLS + проброс на localhost:8765 |
|
||||||
|
| Let's Encrypt cert | `proxy.kube5s.ru` |
|
||||||
|
|
||||||
|
**Почему Python, а не nginx proxy_pass:**
|
||||||
|
nginx не может надёжно проксировать большие multipart/form-data (аудио) в Groq/Cloudflare — соединение обрывается с 408/502. Python `requests` делает нормальный HTTP-запрос от имени сервера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/lang/deploy_lang.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делает скрипт:
|
||||||
|
1. Читает Groq API ключ из `~/lang/token.txt`
|
||||||
|
2. Вшивает ключ в `index.html` через `sed` (в HTML ключ всегда пустая строка)
|
||||||
|
3. rsync всей папки на ВМ (`5.172.178.213`)
|
||||||
|
4. `kubectl apply` configmap + deployment + ingress
|
||||||
|
5. `kubectl rollout restart deploy/lyngvo`
|
||||||
|
|
||||||
|
**Groq ключ** хранится только в `~/lang/token.txt` (в `.gitignore`), в репо не коммитится.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы репо
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html — вся логика приложения (один файл)
|
||||||
|
deploy_lang.sh — скрипт деплоя
|
||||||
|
nginx.conf — конфиг nginx внутри k8s пода
|
||||||
|
k8s/
|
||||||
|
deployment.yaml — Deployment + Service
|
||||||
|
ingress.yaml — Ingress capire.kube5s.ru
|
||||||
|
token.txt — Groq API ключ (в .gitignore)
|
||||||
|
doc/
|
||||||
|
ARCHITECTURE.md — этот файл
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что живёт внутри index.html
|
||||||
|
|
||||||
|
- `VERSION` — строка версии, отображается в заголовке
|
||||||
|
- **phoneticAnalysis(blob)** — фонетический анализ аудио через Web Audio API (pitch, стабильность, артикуляция, качество окончания)
|
||||||
|
- **transcribe(blob)** — отправка аудио в Groq Whisper, возвращает распознанный текст
|
||||||
|
- **translateToRussian(text)** — перевод через Groq LLaMA
|
||||||
|
- **doCompare(blob, text, duration)** — параллельный запуск transcribe + phoneticAnalysis, формирует результат
|
||||||
|
- **История записей** — IndexedDB (`lyngvo_recs`), кнопки ▶ / 🎧 / 📊 / 🗑
|
||||||
|
- **История фраз** — localStorage, последние 5 фраз
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH доступ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ВМ (Kubernetes)
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213
|
||||||
|
|
||||||
|
# Немецкий сервер (Groq прокси)
|
||||||
|
ssh -i ~/.ssh/vultr_openssh -o StrictHostKeyChecking=no root@95.179.252.111
|
||||||
|
```
|
||||||
+260
-85
@@ -9,7 +9,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<style>
|
||||||
body { background: #f8f8fa; font-family: sans-serif; }
|
body { background: #f8f8fa; font-family: sans-serif; }
|
||||||
#app { max-width:600px; margin:40px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:32px; }
|
#app { max-width:900px; margin:20px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:24px 32px; }
|
||||||
textarea { width:100%; font-size:1.2em; border-radius:6px; border:1px solid #ccc; padding:8px; }
|
textarea { width:100%; font-size:1.2em; border-radius:6px; border:1px solid #ccc; padding:8px; }
|
||||||
button { font-size:1em; padding:8px 18px; border-radius:6px; border:none; background:#2a7cff; color:#fff; cursor:pointer; }
|
button { font-size:1em; padding:8px 18px; border-radius:6px; border:none; background:#2a7cff; color:#fff; cursor:pointer; }
|
||||||
button:disabled { background:#ccc; cursor:not-allowed; }
|
button:disabled { background:#ccc; cursor:not-allowed; }
|
||||||
@@ -25,8 +25,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span></h1>
|
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1>
|
||||||
<div style="font-size:0.75em;color:#999;margin-bottom:12px">тренажёр итальянского произношения · для Google Chrome</div>
|
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
|
||||||
|
1. Введите фразу → 2. ▶ Эталон (прослушайте) → 3. 🎙 Записать (произнесите) → 4. 📊 Сравнить
|
||||||
|
</div>
|
||||||
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
||||||
<div id="history"></div>
|
<div id="history"></div>
|
||||||
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
||||||
@@ -48,17 +50,17 @@
|
|||||||
<script>
|
<script>
|
||||||
const GROQ_API_KEY = '';
|
const GROQ_API_KEY = '';
|
||||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
let isRecording = false, micAnalyser = null, micAnimId = null;
|
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||||
let recordStartTime = 0, recordDuration = 0;
|
let recordStartTime = 0, recordDuration = 0;
|
||||||
// ---------- Версия ----------
|
// ---------- Версия ----------
|
||||||
const VERSION = 'v19';
|
const VERSION = 'v39';
|
||||||
document.getElementById('ver').textContent = VERSION;
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
|
||||||
// ---------- Перевод на русский (Groq) ----------
|
// ---------- Перевод на русский (Groq) ----------
|
||||||
async function translateToRussian(text) {
|
async function translateToRussian(text) {
|
||||||
if (!GROQ_API_KEY) return;
|
if (!GROQ_API_KEY) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
const res = await fetch('https://proxy.kube5s.ru/openai/v1/chat/completions', {
|
||||||
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({
|
||||||
@@ -85,6 +87,44 @@ function openRecDB() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syllabifyIT(w) {
|
||||||
|
const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase());
|
||||||
|
const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s);
|
||||||
|
let out = [], syl = '', i = 0;
|
||||||
|
while (i < w.length) {
|
||||||
|
syl += w[i];
|
||||||
|
if (isV(w[i])) {
|
||||||
|
let j = i + 1, cons = '';
|
||||||
|
while (j < w.length && !isV(w[j])) cons += w[j++];
|
||||||
|
if (j < w.length && cons.length > 0) {
|
||||||
|
const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2);
|
||||||
|
syl += cons.slice(0, k);
|
||||||
|
out.push(syl); syl = '';
|
||||||
|
i += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (syl) out.push(syl);
|
||||||
|
return out.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syllabifyPhrase(text) {
|
||||||
|
return text.split(/\s+/).map(syllabifyIT).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTranscription(id, text) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec) { rec.transcription = text; store.put(rec); }
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRecording(blob, word, duration) {
|
async function saveRecording(blob, word, duration) {
|
||||||
try {
|
try {
|
||||||
const db = await openRecDB();
|
const db = await openRecDB();
|
||||||
@@ -126,10 +166,37 @@ async function renderRecHistory() {
|
|||||||
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
|
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
|
||||||
'<button onclick="playRec(' + r.id + ')">▶</button>' +
|
'<button onclick="playRec(' + r.id + ')">▶</button>' +
|
||||||
'<button onclick="stereoRec(' + r.id + ')">🎧</button>' +
|
'<button onclick="stereoRec(' + r.id + ')">🎧</button>' +
|
||||||
|
'<button onclick="compareRec(' + r.id + ')">📊</button>' +
|
||||||
|
(r.transcription ? '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' + syllabifyPhrase(r.transcription) + '</span>' : '') +
|
||||||
|
'<button onclick="deleteRec(' + r.id + ')" style="background:#e44;padding:4px 8px">🗑</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
tx.objectStore('recs').delete(id);
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compareRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readonly');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec?.blob) {
|
||||||
|
document.getElementById('inputText').value = rec.word;
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
|
|
||||||
async function stereoRec(id) {
|
async function stereoRec(id) {
|
||||||
try {
|
try {
|
||||||
const db = await openRecDB();
|
const db = await openRecDB();
|
||||||
@@ -137,14 +204,71 @@ async function stereoRec(id) {
|
|||||||
const store = tx.objectStore('recs');
|
const store = tx.objectStore('recs');
|
||||||
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
if (rec?.blob) {
|
if (rec?.blob) {
|
||||||
const text = rec.word;
|
document.getElementById('inputText').value = rec.word;
|
||||||
document.getElementById('inputText').value = text;
|
await playStereo(rec.word, rec.blob);
|
||||||
ttsText = text;
|
// после стерео — сравнение
|
||||||
await playStereo(text, rec.blob);
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
}
|
}
|
||||||
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doCompare(blob, originalText, duration) {
|
||||||
|
if (isAnalyzing) return '';
|
||||||
|
isAnalyzing = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
setStatus('Анализ... ' + elapsed + 'с');
|
||||||
|
}, 150);
|
||||||
|
const t0 = performance.now();
|
||||||
|
let whisperResult, phon;
|
||||||
|
try {
|
||||||
|
[whisperResult, phon] = await Promise.all([
|
||||||
|
transcribe(blob).then(r => { console.log('[TIMING] transcribe:', ((performance.now()-t0)/1000).toFixed(2)+'s'); return r; }),
|
||||||
|
phoneticAnalysis(blob).then(r => { console.log('[TIMING] phonetic:', ((performance.now()-t0)/1000).toFixed(2)+'s'); return r; })
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
isAnalyzing = false;
|
||||||
|
setStatus('Ошибка анализа.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
clearInterval(timerId);
|
||||||
|
|
||||||
|
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
||||||
|
let recHTML = '', diffHTML = '';
|
||||||
|
|
||||||
|
if (whisperResult.text) {
|
||||||
|
const _transcribed = whisperResult.text;
|
||||||
|
const { score, words } = compareTexts(originalText, whisperResult.text);
|
||||||
|
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
||||||
|
recHTML = recEmoji + ' Распознание: <b>' + score + '%</b> | 🗣 <i>' + whisperResult.text + '</i> | ';
|
||||||
|
diffHTML =
|
||||||
|
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
||||||
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
|
'</div>' +
|
||||||
|
renderDiff(words);
|
||||||
|
} else {
|
||||||
|
recHTML = '';
|
||||||
|
diffHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('score').innerHTML =
|
||||||
|
recHTML + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
||||||
|
|
||||||
|
document.getElementById('diff').innerHTML = diffHTML +
|
||||||
|
'<div class="phonetics">' +
|
||||||
|
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
||||||
|
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
||||||
|
'<span>� Артикуляция: ' + phon.voiceClarity + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
setStatus('Готово.');
|
||||||
|
isAnalyzing = false;
|
||||||
|
return whisperResult.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
async function playRec(id) {
|
async function playRec(id) {
|
||||||
try {
|
try {
|
||||||
const db = await openRecDB();
|
const db = await openRecDB();
|
||||||
@@ -154,8 +278,14 @@ async function playRec(id) {
|
|||||||
if (rec?.blob) {
|
if (rec?.blob) {
|
||||||
const url = URL.createObjectURL(rec.blob);
|
const url = URL.createObjectURL(rec.blob);
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audio.onended = () => URL.revokeObjectURL(url);
|
await new Promise((resolve, reject) => {
|
||||||
audio.play();
|
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
||||||
|
audio.onerror = () => { URL.revokeObjectURL(url); reject(); };
|
||||||
|
audio.play();
|
||||||
|
});
|
||||||
|
// после воспроизведения — сравнение
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
}
|
}
|
||||||
} catch(e) { /* тихо */ }
|
} catch(e) { /* тихо */ }
|
||||||
}
|
}
|
||||||
@@ -183,8 +313,9 @@ function renderHistory() {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
renderHistory();
|
renderHistory();
|
||||||
|
renderRecHistory();
|
||||||
|
|
||||||
// ---------- Фонетический анализ (pitch, стабильность, чистота) ----------
|
// ---------- Фонетический анализ ----------
|
||||||
function phoneticAnalysis(blob) {
|
function phoneticAnalysis(blob) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -196,43 +327,104 @@ function phoneticAnalysis(blob) {
|
|||||||
const sr = buf.sampleRate;
|
const sr = buf.sampleRate;
|
||||||
const len = data.length;
|
const len = data.length;
|
||||||
|
|
||||||
// Pitch по кадрам с 50% перекрытием
|
const frameSize = Math.floor(sr * 0.025); // 25ms
|
||||||
const frame = Math.floor(sr * 0.03); // 30ms
|
const step = Math.floor(frameSize / 2); // 12.5ms, 50% overlap
|
||||||
const step = Math.floor(frame / 2);
|
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
|
||||||
|
|
||||||
|
// Покадровые: RMS-энергия и ZCR
|
||||||
|
const rmsArr = new Float32Array(numFrames);
|
||||||
|
const zcrArr = new Float32Array(numFrames);
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
const s = fi * step;
|
||||||
|
let rmsSum = 0, zcr = 0;
|
||||||
|
for (let i = s; i < s + frameSize; i++) {
|
||||||
|
rmsSum += data[i] * data[i];
|
||||||
|
if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++;
|
||||||
|
}
|
||||||
|
rmsArr[fi] = Math.sqrt(rmsSum / frameSize);
|
||||||
|
zcrArr[fi] = zcr / frameSize * sr; // пересечений/сек
|
||||||
|
}
|
||||||
|
|
||||||
|
// Адаптивный порог энергии (40% от среднего RMS)
|
||||||
|
let meanRms = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
|
||||||
|
meanRms /= numFrames;
|
||||||
|
const energyThresh = Math.max(0.005, meanRms * 0.4);
|
||||||
|
|
||||||
|
// Детекция voiced-кадров + питч (только на voiced)
|
||||||
const pitches = [];
|
const pitches = [];
|
||||||
for (let i = 0; i + frame < len; i += step) {
|
let voicedCount = 0;
|
||||||
const p = pitchFrame(data, i, frame, sr);
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
if (p > 70 && p < 400) pitches.push(p);
|
if (rmsArr[fi] > energyThresh) {
|
||||||
|
voicedCount++;
|
||||||
|
const p = pitchFrame(data, fi * step, frameSize, sr);
|
||||||
|
if (p > 70 && p < 450) pitches.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const voiceRatio = voicedCount / numFrames;
|
||||||
|
|
||||||
|
// 1. Стабильность питча — MAD (robust к выбросам, реалистичный диапазон)
|
||||||
|
let pitchHz = 0, pitchStability = 0;
|
||||||
|
if (pitches.length >= 5) {
|
||||||
|
const sorted = [...pitches].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)];
|
||||||
|
const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)];
|
||||||
|
const relMad = (mad / median) * 100; // % от медианы
|
||||||
|
// <8% отлично, 8-20% хорошо, 20-35% приемлемо, >42% плохо
|
||||||
|
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
|
||||||
|
pitchHz = Math.round(median);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pitchMean = 0, pitchStd = 0, pitchScore = 0;
|
// 2. Артикуляция — ZCR voiced-кадров (чёткость согласных)
|
||||||
const totalFrames = Math.max(1, Math.floor(len / step));
|
const vzArr = [];
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
if (pitches.length >= 3) {
|
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
||||||
pitchMean = pitches.reduce((a,b) => a+b, 0) / pitches.length;
|
}
|
||||||
pitchStd = Math.sqrt(pitches.reduce((s,p) => s + (p-pitchMean)**2, 0) / pitches.length);
|
let articulationScore = 40;
|
||||||
const cv = (pitchStd / pitchMean) * 100;
|
if (vzArr.length > 0) {
|
||||||
// Базовые 25 баллов за наличие голоса + бонус за стабильность
|
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
||||||
pitchScore = Math.round(25 + Math.max(0, Math.min(75, 75 - cv * 4)));
|
// Итальянская речь: 300-1800 ZCR/сек — норма для voiced
|
||||||
|
articulationScore = Math.round(Math.max(0, Math.min(100,
|
||||||
|
mzc < 150 ? mzc / 1.5 :
|
||||||
|
mzc < 500 ? 60 + (mzc - 150) / 350 * 20 :
|
||||||
|
mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 :
|
||||||
|
mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 :
|
||||||
|
Math.max(0, 58 - (mzc - 3000) / 1000 * 20)
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Доля озвученных кадров
|
// 3. Качество окончания (последние 22% записи)
|
||||||
const voiceRatio = pitches.length / totalFrames;
|
// Итальянские слова заканчиваются на гласную → конец должен быть voiced
|
||||||
const clarityScore = Math.round(Math.min(100, voiceRatio * 130));
|
const endStart = Math.floor(numFrames * 0.78);
|
||||||
|
let endVoiced = 0;
|
||||||
|
const endTotal = numFrames - endStart;
|
||||||
|
for (let fi = endStart; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) endVoiced++;
|
||||||
|
}
|
||||||
|
const endingScore = endTotal > 0
|
||||||
|
? Math.round(Math.min(100, (endVoiced / endTotal) * 125))
|
||||||
|
: 50;
|
||||||
|
|
||||||
// Если речь есть, даём минимальный базовый балл
|
// 4. Ясность голоса (оптимальный voiceRatio 0.45-0.70)
|
||||||
const hasSpeech = voiceRatio > 0.05;
|
const clarityScore = Math.round(
|
||||||
const phonScore = hasSpeech
|
voiceRatio < 0.05 ? 0 :
|
||||||
? Math.round(pitchScore * 0.5 + clarityScore * 0.5)
|
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
||||||
: 0;
|
voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 :
|
||||||
|
voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 :
|
||||||
|
Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40)
|
||||||
|
);
|
||||||
|
|
||||||
resolve({
|
// Итоговый балл
|
||||||
pitchHz: Math.round(pitchMean),
|
const hasSpeech = voiceRatio > 0.08;
|
||||||
pitchStability: pitchScore,
|
const phonScore = hasSpeech ? Math.round(
|
||||||
voiceClarity: clarityScore,
|
pitchStability * 0.30 +
|
||||||
phonScore
|
articulationScore * 0.25 +
|
||||||
});
|
clarityScore * 0.25 +
|
||||||
} catch(e) {
|
endingScore * 0.20
|
||||||
|
) : 0;
|
||||||
|
|
||||||
|
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
||||||
|
} catch (err) {
|
||||||
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
|
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -265,7 +457,7 @@ function pitchFrame(data, start, len, sr) {
|
|||||||
if (r > bestR) { bestR = r; bestLag = lag; }
|
if (r > bestR) { bestR = r; bestLag = lag; }
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestR > 0.22 ? sr / bestLag : 0; // смягчённый порог
|
return bestR > 0.30 ? sr / bestLag : 0; // порог (energy pre-filter снижает ложные срабатывания)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
@@ -353,6 +545,10 @@ async function startRecording() {
|
|||||||
isRecording = false;
|
isRecording = false;
|
||||||
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
|
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
|
||||||
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
|
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
|
||||||
|
else {
|
||||||
|
const t = document.getElementById('inputText').value.trim();
|
||||||
|
if (t) saveRecording(userAudioBlob, t, recordDuration);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
mediaRecorder.start(100); // собираем чанки каждые 100ms
|
mediaRecorder.start(100); // собираем чанки каждые 100ms
|
||||||
recordStartTime = Date.now();
|
recordStartTime = Date.now();
|
||||||
@@ -404,20 +600,26 @@ document.getElementById('btnPlayUser').onclick = async () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setStatus('Ошибка воспроизведения — нажмите ещё раз.');
|
setStatus('Ошибка воспроизведения — нажмите ещё раз.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// после воспроизведения — сравнение
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function transcribe(blob) {
|
async function transcribe(blob) {
|
||||||
if (!GROQ_API_KEY) { setStatus('⛔ Нет API ключа Groq!'); return ''; }
|
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
|
||||||
setStatus('Отправка на Whisper...');
|
console.log('[BLOB] size:', blob.size, 'bytes, type:', blob.type);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
|
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
|
||||||
formData.append('model', 'whisper-large-v3');
|
formData.append('model', 'whisper-large-v3');
|
||||||
formData.append('language', 'it');
|
formData.append('language', 'it');
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), 30000);
|
const timer = setTimeout(() => controller.abort(), 60000);
|
||||||
const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
const res = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -425,15 +627,10 @@ async function transcribe(blob) {
|
|||||||
});
|
});
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) return { text: '', error: data.error.message || 'forbidden' };
|
||||||
setStatus('⛔ Whisper: ' + (data.error.message || 'Forbidden — нужен VPN или новый ключ'));
|
return { text: data.text || '', error: null };
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return data.text || '';
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') setStatus('⛔ Whisper: таймаут (30с). Проверьте VPN/интернет.');
|
return { text: '', error: err.name === 'AbortError' ? 'timeout' : 'network' };
|
||||||
else setStatus('⛔ Whisper: ' + (err.message || 'сетевая ошибка. Нужен VPN?'));
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,40 +713,18 @@ document.getElementById('btnTTS').onclick = async () => {
|
|||||||
document.getElementById('btnCompare').onclick = async () => {
|
document.getElementById('btnCompare').onclick = async () => {
|
||||||
if (!userAudioBlob) return setStatus('Нет записи!');
|
if (!userAudioBlob) return setStatus('Нет записи!');
|
||||||
const original = document.getElementById('inputText').value.trim();
|
const original = document.getElementById('inputText').value.trim();
|
||||||
setStatus('Анализ...');
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
const [transcribed, phon] = await Promise.all([
|
|
||||||
transcribe(userAudioBlob),
|
|
||||||
phoneticAnalysis(userAudioBlob)
|
|
||||||
]);
|
|
||||||
if (!transcribed) return setStatus('Whisper не распознал речь.');
|
|
||||||
const { score, words } = compareTexts(original, transcribed);
|
|
||||||
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
|
||||||
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
|
||||||
|
|
||||||
document.getElementById('score').innerHTML =
|
|
||||||
recEmoji + ' Распознавание: <b>' + score + '%</b>' +
|
|
||||||
' | ' + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
|
||||||
|
|
||||||
document.getElementById('diff').innerHTML =
|
|
||||||
'<div style="margin-bottom:6px;color:#666;font-size:0.9em">' +
|
|
||||||
'🗣 Whisper: <b>' + transcribed + '</b> · ⏱ ' + recordDuration.toFixed(1) + 'с' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="phonetics">' +
|
|
||||||
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
|
||||||
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
|
||||||
'<span>🔊 Чистота: ' + phon.voiceClarity + '%</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
|
||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
|
||||||
'</div>' +
|
|
||||||
renderDiff(words);
|
|
||||||
setStatus('Готово.');
|
|
||||||
};
|
};
|
||||||
document.getElementById('btnStereo').onclick = async () => {
|
document.getElementById('btnStereo').onclick = async () => {
|
||||||
const text = ttsText || document.getElementById('inputText').value.trim();
|
const text = document.getElementById('inputText').value.trim();
|
||||||
if (!text) return setStatus('Введите текст для эталона!');
|
if (!text) return setStatus('Введите текст для эталона!');
|
||||||
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
||||||
await playStereo(text, userAudioBlob);
|
await playStereo(text, userAudioBlob);
|
||||||
|
// после стерео — сравнение
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user