Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
450e95eb19 | ||
|
|
415b27edc2 | ||
|
|
2d94980038 | ||
|
|
6fe6f77c07 | ||
|
|
7302d6ef62 | ||
|
|
3d6fb6107c | ||
|
|
eb22ad2074 | ||
|
|
d8b3643da3 | ||
|
|
c83e3d9da2 | ||
|
|
4acc400fab | ||
|
|
a091548925 | ||
|
|
23644bf214 | ||
|
|
2017a6e4be | ||
|
|
2cf853be56 | ||
|
|
625c24b695 | ||
|
|
babf26cf6d | ||
|
|
73e9efad9c | ||
|
|
dababcf466 | ||
|
|
6b62ec8dd5 | ||
|
|
f7587a3dcb | ||
|
|
689fa78734 | ||
|
|
80cee77afc | ||
|
|
201ca095e7 | ||
|
|
337f7f3017 | ||
|
|
66a3e32c03 | ||
|
|
c0658f6c20 | ||
|
|
f2762b4f0a | ||
|
|
6ab7a26bbc | ||
|
|
f056fda81a | ||
|
|
378bedc289 | ||
|
|
cbb5b4917a | ||
|
|
d9ecab051d | ||
|
|
83140ded66 | ||
|
|
1baa3e841c | ||
|
|
67b82c726e | ||
|
|
1f08e3a525 | ||
|
|
ceebfc5d58 | ||
|
|
60749a9691 | ||
|
|
77a75235ad | ||
|
|
abb943dd52 | ||
|
|
caec0eba0b | ||
|
|
aba175442b | ||
|
|
9c13c21898 | ||
|
|
bd042be61c | ||
|
|
53de41a90e | ||
|
|
b71eb9a45f | ||
|
|
1f3dce39a4 | ||
|
|
4945510cb2 | ||
|
|
786b937acd | ||
|
|
96f178b56e | ||
|
|
3442e5d031 | ||
|
|
f4957437db | ||
|
|
628fea51b7 | ||
|
|
ff6eb0486c | ||
|
|
0504c6009c | ||
|
|
8fc986fe7f | ||
|
|
f4ac9cf717 | ||
|
|
8f8a21bbcc | ||
|
|
26c5adfc79 | ||
|
|
86e7db4b77 |
@@ -1,75 +1,20 @@
|
|||||||
# Правила
|
# Lyngvo — Copilot Instructions
|
||||||
|
|
||||||
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
|
## Ключевые правила
|
||||||
- Вопрос → короткий ответ → СТОП.
|
1. Отвечать кратко. Вопрос = СТОП.
|
||||||
- Ничего лишнего.
|
2. Не трогать рабочий код без «делай».
|
||||||
- Код — только по запросу.
|
3. После любой правки — VERSION++ в js/main.js.
|
||||||
|
4. Деплой: `cd ~/lang && bash deploy_lang.sh`
|
||||||
|
5. Git только локально: add + commit + push.
|
||||||
|
|
||||||
## ⛔⛔⛔ ВОПРОС = СТОП
|
## Сервер
|
||||||
|
- Германия, Vultr, 95.179.252.111
|
||||||
|
- SSH: `ssh -i ~/.ssh/vultr_openssh root@95.179.252.111`
|
||||||
|
- Сайт: https://lang.kube5s.ru
|
||||||
|
- Nginx + Let's Encrypt + Groq прокси (gunicorn :8765)
|
||||||
|
|
||||||
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
|
## Стек
|
||||||
1. Только ответить на вопрос
|
- Статика: HTML/CSS/JS (без фреймворков)
|
||||||
2. ОСТАНОВИТЬСЯ
|
- Сборка: build.sh склеивает CSS/JS в dist/index.html
|
||||||
3. ЖДАТЬ следующей команды
|
- Деплой: scp dist/index.html → /var/www/lyngvo/
|
||||||
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
- API: Groq Whisper + Llama через /openai/ прокси
|
||||||
|
|
||||||
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
|
|
||||||
|
|
||||||
**НИКАКИХ самодеятельных изменений рабочего кода:**
|
|
||||||
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
|
|
||||||
- Никаких новых фич без явного разрешения
|
|
||||||
- Никаких helm upgrade, kubectl patch и прочих инфраструктурных изменений без команды
|
|
||||||
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
|
|
||||||
|
|
||||||
**ПРЕЦЕДЕНТЫ:**
|
|
||||||
- v1.3.49: убрали mode toggle в edit modal "для улучшения" → сломали рабочий редактор
|
|
||||||
- helm upgrade попытка → сломал JWT secret router → 401 у всех функций пользователей
|
|
||||||
|
|
||||||
1. Не трогать рабочий код без явного указания.
|
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
|
||||||
~/lang
|
|
||||||
|
|
||||||
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
|
||||||
rsync -az \
|
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
|
||||||
~/lang/ \
|
|
||||||
naeel@5.172.178.213:~/terra/lang/
|
|
||||||
|
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/lang
|
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
|
||||||
|
|
||||||
- не выполнять инфраструктурные команды локально
|
|
||||||
- не открывать интерактивные сессии
|
|
||||||
- не делать цепочки без необходимости
|
|
||||||
|
|
||||||
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
|
|
||||||
|
|
||||||
5. ЗАПРЕЩЕНО:
|
|
||||||
- откатывать код
|
|
||||||
- менять версии
|
|
||||||
- ломать рабочее состояние
|
|
||||||
|
|
||||||
6. После каждого исправления:
|
|
||||||
- git add/commit ЛОКАЛЬНО
|
|
||||||
- затем синхронизация (rsync) на ВМ
|
|
||||||
|
|
||||||
7. ~/lang/test-results/
|
|
||||||
|
|
||||||
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
|
|
||||||
|
|
||||||
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
|
|
||||||
- Не расширять масштаб работы
|
|
||||||
- Не выполнять "логичные следующие шаги"
|
|
||||||
- Не инициировать дополнительные операции
|
|
||||||
- Не делать ничего кроме того что сказано
|
|
||||||
|
|
||||||
**Пример (2026-05-01):**
|
|
||||||
- Приказано: "собери"
|
|
||||||
- Сделано: ✓ собрал образы v1.3.17 и v0.1.2
|
|
||||||
- СТОП — жду команды дальше
|
|
||||||
- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты
|
|
||||||
|
|
||||||
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
|
|
||||||
|
|||||||
+28
-97
@@ -1,107 +1,38 @@
|
|||||||
# Правила работы агента
|
# Правила работы агента — Lyngvo (германская ВМ)
|
||||||
|
|
||||||
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
## ⛔ ВЕРСИЯ — УВЕЛИЧИВАТЬ ПОСЛЕ ЛЮБОЙ ПРАВКИ
|
||||||
|
После ЛЮБОГО изменения кода — VERSION++ в js/main.js.
|
||||||
|
|
||||||
1. УВЕЛИЧИТЬ ТЕГ в `deploy/lang.yaml` (vX.Y.Z → vX.Y.Z+1)
|
## ⛔ ОТВЕЧАТЬ КРАТКО — вопрос = СТОП
|
||||||
2. rsync на ВМ
|
|
||||||
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
|
|
||||||
4. docker build с НОВЫМ тегом
|
|
||||||
5. docker push с НОВЫМ тегом
|
|
||||||
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
|
|
||||||
|
|
||||||
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
|
## ⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД без явного «делай»
|
||||||
|
|
||||||
## Файловая система (актуально)
|
## ⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО
|
||||||
|
|
||||||
1. Все файлы редактируются локально: `~/lang`
|
## Файловая система
|
||||||
2. После любых изменений — обязательно rsync на ВМ:
|
- Редактировать локально: ~/lang
|
||||||
rsync -az \
|
- Git: только локально. add + commit + push. Остальное запрещено.
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
|
||||||
~/lang/ \
|
|
||||||
naeel@5.172.178.213:~/terra/lang/
|
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/lang
|
## Деплой
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
|
||||||
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
|
||||||
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
|
||||||
7. Только rsync для синхронизации
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
1. Редактируешь локально
|
|
||||||
2. rsync на ВМ
|
|
||||||
3. Выполняешь команды через SSH на ВМ
|
|
||||||
|
|
||||||
## SSH
|
|
||||||
|
|
||||||
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
|
||||||
```
|
```
|
||||||
|
cd ~/lang && bash deploy_lang.sh
|
||||||
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
- `doc/thinking/` — лог рассуждений агента (обязательно)
|
|
||||||
- `doc/progress.md` — трекер задач
|
|
||||||
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
|
||||||
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/lang`. НИКОГДА через SSH на VM.
|
|
||||||
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
|
|
||||||
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
|
|
||||||
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
|
||||||
|
|
||||||
Версионирование тегами: `vMAJOR.MINOR.PATCH`
|
|
||||||
- Patch — любое изменение кода
|
|
||||||
- Minor — новая фича / компонент
|
|
||||||
- Major — breaking change
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag vX.Y.Z && git push origin vX.Y.Z
|
|
||||||
```
|
```
|
||||||
|
build.sh → dist/index.html → scp на германскую ВМ.
|
||||||
|
|
||||||
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
## Германская ВМ (Vultr)
|
||||||
|
- IP: 95.179.252.111
|
||||||
|
- SSH: ssh -i ~/.ssh/vultr_openssh root@95.179.252.111
|
||||||
|
- Статика: /var/www/lyngvo/index.html
|
||||||
|
- Nginx: /etc/nginx/conf.d/lang.kube5s.ru.conf
|
||||||
|
- Groq прокси: gunicorn 127.0.0.1:8765 (systemctl: groq-proxy)
|
||||||
|
- Домен: lang.kube5s.ru (Let's Encrypt)
|
||||||
|
|
||||||
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
## Структура
|
||||||
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
lang/
|
||||||
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
├── index.html каркас
|
||||||
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
├── css/style.css
|
||||||
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
├── js/ storage | syllables | transcribe | audio | compare | main
|
||||||
|
├── build.sh склейка → dist/
|
||||||
## Документация тест-прогонов — В РЕАЛЬНОМ ВРЕМЕНИ
|
├── dist/index.html собранный (деплоится)
|
||||||
|
├── deploy_lang.sh build + scp
|
||||||
**Правила:**
|
└── doc/
|
||||||
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
|
|
||||||
2. Запускать `run_all.sh 2>&1 | tee ~/terra/lang/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
|
|
||||||
3. После завершения — rsync лога локально. Лог остаётся как документация.
|
|
||||||
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
|
|
||||||
|
|
||||||
**Формат запуска:**
|
|
||||||
```bash
|
|
||||||
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
|
||||||
"bash ~/terra/lang/scripts/run_all.sh 2>&1 | tee ~/terra/lang/${LOG}"
|
|
||||||
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
|
||||||
naeel@5.172.178.213:~/terra/lang/test-results/ ~/lang/test-results/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
|
||||||
|
|
||||||
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
|
||||||
|
|
||||||
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
|
||||||
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
|
||||||
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
|
||||||
- Исключение: только если пользователь явно написал "примени ручной патч".
|
|
||||||
|
|
||||||
## Поведение агента
|
|
||||||
|
|
||||||
- Не трогать рабочий код без явного указания
|
|
||||||
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
|
|
||||||
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
|
|
||||||
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
|
||||||
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
|
||||||
|
|||||||
@@ -1,392 +0,0 @@
|
|||||||
# Lyngvo — инструкция для реализации MVP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⛔⛔⛔ ПРАВИЛО ДЛЯ АГЕНТА — ЧИТАТЬ ПЕРВЫМ И ВЫПОЛНЯТЬ БЕЗУСЛОВНО
|
|
||||||
|
|
||||||
**ЗАПРЕЩЕНО:**
|
|
||||||
- Задавать вопросы пользователю
|
|
||||||
- Просить уточнений
|
|
||||||
- Писать "уточните...", "какой API ключ?", "какой домен?", "подтвердите..."
|
|
||||||
- Останавливаться в середине работы
|
|
||||||
- Писать "продолжить?" или "нужно ли мне..."
|
|
||||||
- Выдавать список шагов вместо готового кода
|
|
||||||
|
|
||||||
**ОБЯЗАТЕЛЬНО:**
|
|
||||||
- Прочитать всю инструкцию до конца
|
|
||||||
- Принять все решения самостоятельно на основе инструкции
|
|
||||||
- Создать все файлы сразу, полностью, без пропусков
|
|
||||||
- Если что-то не указано — выбрать разумный вариант и сделать
|
|
||||||
- Остановиться только когда ВСЕ файлы готовы и работают
|
|
||||||
|
|
||||||
**Единственный допустимый результат:** готовые файлы index.html, nginx.conf, k8s/deployment.yaml, k8s/ingress.yaml — рабочие, без заглушек, без TODO.
|
|
||||||
|
|
||||||
**GROQ API KEY для STT (Whisper):** читать из файла `token.txt` в той же папке, вставить в код как константу.
|
|
||||||
|
|
||||||
**Домен:** capire.kube5s.ru
|
|
||||||
|
|
||||||
**SSH на ВМ:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213`
|
|
||||||
|
|
||||||
**Деплой:** запустить `./deploy_lang.sh` — скрипт уже есть, он делает rsync + kubectl apply.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Задача
|
|
||||||
|
|
||||||
Создать веб-приложение для тренировки итальянского произношения.
|
|
||||||
Всё в одном файле `index.html` (HTML + CSS + JS inline). Никаких фреймворков, никаких npm.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Файловая структура
|
|
||||||
|
|
||||||
```
|
|
||||||
/home/naeel/lang/
|
|
||||||
index.html ← весь код здесь
|
|
||||||
nginx.conf ← конфиг для деплоя
|
|
||||||
k8s/
|
|
||||||
deployment.yaml
|
|
||||||
ingress.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Пользовательский сценарий (строго в этом порядке)
|
|
||||||
|
|
||||||
1. Пользователь вводит итальянское слово/фразу в поле
|
|
||||||
2. Нажимает **"Воспроизвести эталон"** → браузер синтезирует речь итальянским голосом
|
|
||||||
3. Нажимает **"Записать"** → идёт запись с микрофона (кнопка меняется на "Стоп")
|
|
||||||
4. Нажимает **"Стоп"** → запись останавливается
|
|
||||||
5. Нажимает **"Сравнить"** → запись уходит на Whisper API, приходит транскрипция
|
|
||||||
6. На экране:
|
|
||||||
- **Similarity score** (число от 0 до 100%)
|
|
||||||
- **Diff**: слова оригинала подсвечены зелёным (совпало) / красным (не совпало)
|
|
||||||
7. Нажимает **"Стерео-воспроизведение"** → левое ухо: TTS-эталон, правое ухо: голос пользователя
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 1 — TTS (Web Speech API)
|
|
||||||
|
|
||||||
```js
|
|
||||||
function playTTS(text) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const utter = new SpeechSynthesisUtterance(text);
|
|
||||||
utter.lang = 'it-IT';
|
|
||||||
|
|
||||||
// ОБЯЗАТЕЛЬНО: выбрать итальянский голос явно
|
|
||||||
const voices = speechSynthesis.getVoices();
|
|
||||||
const italianVoice = voices.find(v => v.lang.startsWith('it'));
|
|
||||||
if (italianVoice) utter.voice = italianVoice;
|
|
||||||
|
|
||||||
utter.onend = resolve;
|
|
||||||
speechSynthesis.speak(utter);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ВАЖНО: getVoices() асинхронный при первом вызове.
|
|
||||||
// Вешать на speechSynthesis.onvoiceschanged или вызывать после события:
|
|
||||||
speechSynthesis.onvoiceschanged = () => { /* голоса загружены */ };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 2 — Запись (MediaRecorder)
|
|
||||||
|
|
||||||
```js
|
|
||||||
let mediaRecorder;
|
|
||||||
let audioChunks = [];
|
|
||||||
let userAudioBlob = null;
|
|
||||||
|
|
||||||
async function startRecording() {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
audioChunks = [];
|
|
||||||
mediaRecorder = new MediaRecorder(stream);
|
|
||||||
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
userAudioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
||||||
// остановить треки микрофона
|
|
||||||
stream.getTracks().forEach(t => t.stop());
|
|
||||||
};
|
|
||||||
mediaRecorder.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
mediaRecorder.stop();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 3 — Whisper STT (Groq API)
|
|
||||||
|
|
||||||
**Endpoint:** `https://api.groq.com/openai/v1/audio/transcriptions`
|
|
||||||
**Model:** `whisper-large-v3`
|
|
||||||
**Ключ:** вписать в константу `GROQ_API_KEY` в начале JS-кода
|
|
||||||
|
|
||||||
```js
|
|
||||||
const GROQ_API_KEY = 'ВСТАВИТЬ_КЛЮЧ_ЗДЕСЬ';
|
|
||||||
|
|
||||||
async function transcribe(blob) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
|
|
||||||
formData.append('model', 'whisper-large-v3');
|
|
||||||
formData.append('language', 'it');
|
|
||||||
|
|
||||||
const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return data.text || '';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 4 — Сравнение текстов
|
|
||||||
|
|
||||||
```js
|
|
||||||
function normalize(text) {
|
|
||||||
return text.toLowerCase().replace(/[.,!?;:«»""'']/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareTexts(original, transcribed) {
|
|
||||||
const a = normalize(original).split(/\s+/);
|
|
||||||
const b = normalize(transcribed).split(/\s+/);
|
|
||||||
|
|
||||||
let matched = 0;
|
|
||||||
const result = a.map(word => {
|
|
||||||
const found = b.includes(word);
|
|
||||||
if (found) matched++;
|
|
||||||
return { word, ok: found };
|
|
||||||
});
|
|
||||||
|
|
||||||
const score = Math.round((matched / Math.max(a.length, b.length)) * 100);
|
|
||||||
return { score, words: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рендер diff:
|
|
||||||
function renderDiff(words) {
|
|
||||||
return words.map(({ word, ok }) =>
|
|
||||||
`<span style="color:${ok ? 'green' : 'red'}; font-weight:bold">${word}</span>`
|
|
||||||
).join(' ');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 5 — Стерео-воспроизведение (Web Audio API)
|
|
||||||
|
|
||||||
**Главный нюанс:** Web Speech API нельзя напрямую захватить в AudioBuffer.
|
|
||||||
Решение: воспроизводить TTS через `audio`-элемент с `captureStream()` и пускать через PannerNode.
|
|
||||||
|
|
||||||
```js
|
|
||||||
async function playStereo(ttsText, userBlob) {
|
|
||||||
const ctx = new AudioContext();
|
|
||||||
|
|
||||||
// --- Правый канал: голос пользователя ---
|
|
||||||
const userURL = URL.createObjectURL(userBlob);
|
|
||||||
const userAudio = new Audio(userURL);
|
|
||||||
const userSource = ctx.createMediaElementSource(userAudio);
|
|
||||||
const userPan = ctx.createStereoPanner();
|
|
||||||
userPan.pan.value = 1; // правое ухо
|
|
||||||
userSource.connect(userPan).connect(ctx.destination);
|
|
||||||
|
|
||||||
// --- Левый канал: TTS через скрытый audio-элемент ---
|
|
||||||
// Используем SpeechSynthesisUtterance + MediaStreamDestination
|
|
||||||
const ttsDestination = ctx.createMediaStreamDestination();
|
|
||||||
const ttsAudio = new Audio();
|
|
||||||
ttsAudio.srcObject = ttsDestination.stream;
|
|
||||||
const ttsSource = ctx.createMediaElementSource(ttsAudio);
|
|
||||||
const ttsPan = ctx.createStereoPanner();
|
|
||||||
ttsPan.pan.value = -1; // левое ухо
|
|
||||||
ttsSource.connect(ttsPan).connect(ctx.destination);
|
|
||||||
|
|
||||||
// АЛЬТЕРНАТИВА (проще и надёжнее если выше не работает в браузере):
|
|
||||||
// Просто запустить TTS и userAudio одновременно без захвата потока,
|
|
||||||
// управляя балансом через два отдельных audio-элемента с pan через Web Audio.
|
|
||||||
// TTS-элемент: pan = -1, user-элемент: pan = +1.
|
|
||||||
|
|
||||||
// Простой вариант (рекомендуется для MVP):
|
|
||||||
await Promise.all([
|
|
||||||
new Promise(res => {
|
|
||||||
const utter = new SpeechSynthesisUtterance(ttsText);
|
|
||||||
utter.lang = 'it-IT';
|
|
||||||
const voices = speechSynthesis.getVoices();
|
|
||||||
const v = voices.find(v => v.lang.startsWith('it'));
|
|
||||||
if (v) utter.voice = v;
|
|
||||||
utter.onend = res;
|
|
||||||
speechSynthesis.speak(utter);
|
|
||||||
}),
|
|
||||||
new Promise(res => {
|
|
||||||
userAudio.onended = res;
|
|
||||||
userAudio.play();
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Если браузер блокирует AudioContext до жеста пользователя** — создавать `ctx` только внутри обработчика клика (не на старте страницы).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI (минимальный, но понятный)
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div id="app" style="max-width:600px; margin:40px auto; font-family:sans-serif">
|
|
||||||
<h1>🇮🇹 Lyngvo</h1>
|
|
||||||
|
|
||||||
<textarea id="inputText" rows="3" style="width:100%; font-size:1.2em"
|
|
||||||
placeholder="Введите итальянское слово или фразу..."></textarea>
|
|
||||||
|
|
||||||
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
|
||||||
<button id="btnTTS">▶ Эталон</button>
|
|
||||||
<button id="btnRecord">🎙 Записать</button>
|
|
||||||
<button id="btnCompare" disabled>📊 Сравнить</button>
|
|
||||||
<button id="btnStereo" disabled>🎧 Стерео</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status" style="color:gray; font-size:0.9em"></div>
|
|
||||||
<div id="score" style="font-size:1.5em; margin:12px 0"></div>
|
|
||||||
<div id="diff" style="font-size:1.2em; line-height:1.8"></div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## nginx.conf
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name lang.kube5s.ru;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## k8s/deployment.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: lyngvo
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: lyngvo
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: lyngvo
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
||||||
volumeMounts:
|
|
||||||
- name: html
|
|
||||||
mountPath: /usr/share/nginx/html
|
|
||||||
- name: nginx-conf
|
|
||||||
mountPath: /etc/nginx/conf.d/default.conf
|
|
||||||
subPath: default.conf
|
|
||||||
volumes:
|
|
||||||
- name: html
|
|
||||||
configMap:
|
|
||||||
name: lyngvo-html
|
|
||||||
- name: nginx-conf
|
|
||||||
configMap:
|
|
||||||
name: lyngvo-nginx
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: lyngvo
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## k8s/ingress.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- lang.kube5s.ru
|
|
||||||
secretName: lyngvo-tls
|
|
||||||
rules:
|
|
||||||
- host: lang.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: lyngvo
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Деплой (выполнять на ВМ naeel@5.172.178.213)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Создать ConfigMap из файлов
|
|
||||||
kubectl create configmap lyngvo-html --from-file=index.html -n default --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
kubectl create configmap lyngvo-nginx --from-file=default.conf=nginx.conf -n default --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
|
||||||
# 2. Применить манифесты
|
|
||||||
kubectl apply -f k8s/deployment.yaml
|
|
||||||
kubectl apply -f k8s/ingress.yaml
|
|
||||||
|
|
||||||
# 3. После изменения index.html — обновить ConfigMap и рестартовать pod:
|
|
||||||
kubectl create configmap lyngvo-html --from-file=index.html -n default --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
kubectl rollout restart deployment/lyngvo -n default
|
|
||||||
```
|
|
||||||
|
|
||||||
**SSH:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что НЕ делать
|
|
||||||
|
|
||||||
- Не использовать React, Vue, webpack, npm
|
|
||||||
- Не использовать OpenAI Whisper API (дорогой) — только Groq
|
|
||||||
- Не создавать бэкенд — всё в браузере, Groq вызывается напрямую из JS
|
|
||||||
- Не добавлять ничего кроме описанного выше — это MVP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Порядок реализации
|
|
||||||
|
|
||||||
1. `index.html` — полный рабочий код (TTS + запись + Whisper + diff + стерео)
|
|
||||||
2. `nginx.conf`
|
|
||||||
3. `k8s/deployment.yaml` + `k8s/ingress.yaml`
|
|
||||||
4. Инструкция по деплою выше
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Склеивает CSS/JS в единый index.html для деплоя (scp на германскую ВМ)."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
html = open(os.path.join(BASE, 'index.html')).read()
|
||||||
|
|
||||||
|
css = open(os.path.join(BASE, 'css/style.css')).read()
|
||||||
|
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
||||||
|
f'<style>\n{css}\n</style>')
|
||||||
|
|
||||||
|
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()
|
||||||
|
html = html.replace(f'<script src="js/{jsf}"></script>',
|
||||||
|
f'<script>\n{js}\n</script>')
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(BASE, 'dist'), exist_ok=True)
|
||||||
|
with open(os.path.join(BASE, 'dist/index.html'), 'w') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print(f'dist/index.html ready ({len(html)} bytes)')
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "--- PODS ---"
|
|
||||||
kubectl get pods -n default | grep lyngvo || true
|
|
||||||
|
|
||||||
echo "\n--- SERVICES ---"
|
|
||||||
kubectl get svc -n default | grep lyngvo || true
|
|
||||||
|
|
||||||
echo "\n--- INGRESS ---"
|
|
||||||
kubectl get ingress -n default | grep lyngvo || true
|
|
||||||
|
|
||||||
echo "\n--- POD LOGS (последние 50 строк) ---"
|
|
||||||
kubectl logs deploy/lyngvo -n default --tail=50 || true
|
|
||||||
|
|
||||||
echo "\n--- DESCRIBE INGRESS ---"
|
|
||||||
kubectl describe ingress lyngvo -n default || true
|
|
||||||
|
|
||||||
echo "\n--- DESCRIBE SERVICE ---"
|
|
||||||
kubectl describe svc lyngvo -n default || true
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* Lyngvo — Итальянский тренажёр произношения */
|
||||||
|
body { background: #f8f8fa; font-family: sans-serif; }
|
||||||
|
#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; }
|
||||||
|
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; }
|
||||||
|
#score { font-size:1.5em; margin:12px 0; }
|
||||||
|
#diff { font-size:1.2em; line-height:1.8; }
|
||||||
|
#history { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
|
||||||
|
#history span { font-size:0.85em; background:#e8e8f0; padding:3px 10px; border-radius:12px; cursor:pointer; }
|
||||||
|
#history span:hover { background:#d0d0e0; }
|
||||||
|
.phonetics { font-size:0.85em; color:#666; margin-top:6px; display:flex; gap:16px; flex-wrap:wrap; }
|
||||||
|
.recItem { display:flex; align-items:center; gap:8px; padding:4px 8px; background:#f0f0f5; border-radius:8px; margin:4px 0; }
|
||||||
|
.recItem button { font-size:0.8em; padding:4px 10px; }
|
||||||
|
.syl-chip { cursor:pointer; padding:2px 4px; border-radius:4px; transition:0.15s; user-select:none; }
|
||||||
|
.syl-chip:hover { background:#e0e0ff; }
|
||||||
|
.syl-chip:active { background:#b0b0ff; }
|
||||||
+19
-19
@@ -1,25 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
VM="root@95.179.252.111"
|
||||||
|
SSH="ssh -i ~/.ssh/vultr_openssh -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
# Вклеиваем Groq API ключ в index.html
|
echo "=== build ==="
|
||||||
KEY=$(cat ~/lang/token.txt)
|
~/lang/.venv/bin/python3 ~/lang/build.sh
|
||||||
cp ~/lang/index.html /tmp/lyngvo_index.html
|
|
||||||
sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html
|
|
||||||
|
|
||||||
# Синхронизация всей папки lang на ВМ
|
echo "=== inject API key ==="
|
||||||
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" ~/lang/ naeel@5.172.178.213:~/terra/lang/
|
KEY=$(cat ~/lang/token_ru.txt)
|
||||||
|
python3 -c "
|
||||||
|
h = open('$HOME/lang/dist/index.html').read()
|
||||||
|
h = h.replace(\"const GROQ_API_KEY = '';\", \"const GROQ_API_KEY = '$KEY';\")
|
||||||
|
open('$HOME/lang/dist/index.html', 'w').write(h)
|
||||||
|
"
|
||||||
|
echo " done"
|
||||||
|
|
||||||
# Деплой на ВМ (index.html с ключом — через временный файл)
|
echo "=== rsync -> VM ==="
|
||||||
scp -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 /tmp/lyngvo_index.html naeel@5.172.178.213:~/terra/lang/index.html
|
rsync -az -e "$SSH" /home/naeel/lang/dist/ ${VM}:/var/www/lyngvo/
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 '
|
|
||||||
cd ~/terra/lang && \
|
|
||||||
kubectl create configmap lyngvo-html --from-file=index.html -n default --dry-run=client -o yaml | kubectl apply -f - && \
|
|
||||||
kubectl create configmap lyngvo-nginx --from-file=default.conf=nginx.conf -n default --dry-run=client -o yaml | kubectl apply -f - && \
|
|
||||||
kubectl apply -f k8s/deployment.yaml && \
|
|
||||||
kubectl apply -f k8s/ingress.yaml && \
|
|
||||||
kubectl rollout restart deploy/lyngvo -n default && \
|
|
||||||
kubectl rollout status deploy/lyngvo -n default --timeout=60s
|
|
||||||
'
|
|
||||||
|
|
||||||
rm -f /tmp/lyngvo_index.html
|
echo "=== update server ==="
|
||||||
echo "\n=== Деплой завершён. Проверяй capire.kube5s.ru ==="
|
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 ==="
|
||||||
|
|||||||
Vendored
+1142
File diff suppressed because it is too large
Load Diff
+24
-246
@@ -1,263 +1,41 @@
|
|||||||
# Архитектура Lyngvo — проксирование Whisper через немецкий сервер
|
# Архитектура Lyngvo (germany, 2026-05-22)
|
||||||
|
|
||||||
## Общая схема
|
## Общая схема
|
||||||
|
|
||||||
```
|
```
|
||||||
Браузер (Россия) Немецкий сервер (Vultr) Groq API
|
Браузер (Россия) Германская ВМ (Vultr, 95.179.252.111) Groq API
|
||||||
┌─────────────────┐ HTTPS/WSS ┌──────────────────────────┐ HTTPS ┌──────────┐
|
┌──────────────────┐ HTTPS ┌──────────────────────────────────┐ HTTPS ┌──────────┐
|
||||||
│ capire.kube5s.ru │ ───────────────→│ proxy.kube5s.ru │ ──────────→│ api.groq │
|
│ lang.kube5s.ru │ ────────────→│ nginx:443 │ ──────→│ api.groq │
|
||||||
│ (K8s, index.html)│ │ │ │ .com │
|
│ (SPA, index.html)│ │ │ │ .com │
|
||||||
└─────────────────┘ │ nginx:443 │ └──────────┘
|
└──────────────────┘ │ / → /var/www/lyngvo/ │ └──────────┘
|
||||||
│ ├─ /ws → ws_server:8766 │
|
│ /openai/* → gunicorn:8765 │
|
||||||
│ └─ / → gunicorn:8765 │
|
|
||||||
│ └─ Flask proxy.py │
|
│ └─ Flask proxy.py │
|
||||||
└──────────────────────────┘
|
│ /api/* → :8080 (будущий) │
|
||||||
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Компоненты на немецком сервере (95.179.252.111)
|
## Компоненты
|
||||||
|
|
||||||
| Порт | Протокол | Сервис | Назначение |
|
| Порт | Сервис | Назначение |
|
||||||
|------|----------|--------|------------|
|
|------|--------|------------|
|
||||||
| 443 TCP | HTTPS | nginx | Терминирует TLS, проксирует в gunicorn/ws_server |
|
| 443 | nginx 1.31 | Терминирует TLS, раздаёт статику, проксирует /openai/ и /api/ |
|
||||||
| 443 UDP | Hysteria2 | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
| 8765 | gunicorn (Flask proxy.py) | Пробрасывает запросы в api.groq.com |
|
||||||
| 8443 TCP | xray | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
|
||||||
| 8765 TCP | HTTP | gunicorn (Flask) | Прямые POST-запросы (маленькие файлы ≤14KB) |
|
|
||||||
| 8766 TCP | WS | python3 ws_server.py | WebSocket (большие файлы >14KB) |
|
|
||||||
|
|
||||||
## Ошибка 1: Whisper заблокирован в России
|
## Деплой
|
||||||
|
|
||||||
**Симптом:** nginx в K8s не мог проксировать multipart POST на api.groq.com — Groq/Cloudflare возвращали 408 или connection hang.
|
|
||||||
|
|
||||||
**Причина:** Groq блокирует прямой доступ из российских IP.
|
|
||||||
|
|
||||||
**Решение:** Прокси-сервер на немецком Vultr (95.179.252.111). Сначала пробовали nginx `proxy_pass` напрямую — не работало для больших файлов (>~10KB). Затем Python-прокси.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 2: Python http.server не держит большие multipart
|
|
||||||
|
|
||||||
**Симптом:** `BaseHTTPRequestHandler` молча падал (HTTP:000) для тел >~3KB multipart.
|
|
||||||
|
|
||||||
**Причина:** http.server плохо обрабатывает большие multipart-тела (баг/ограничение модуля).
|
|
||||||
|
|
||||||
**Решение:** Flask + gunicorn (4 воркера). Файл: `/opt/groq-proxy/proxy.py`, systemd unit: `groq-proxy.service`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 3: blobToWav — файлы стали 3-5x больше
|
|
||||||
|
|
||||||
**Симптом:** Добавили функцию `blobToWav()` (v33) — конвертация WebM→WAV перед отправкой. Файлы выросли с ~20KB до ~64KB, грузились 30+ секунд и отваливались по таймауту.
|
|
||||||
|
|
||||||
**Причина:** WAV — несжатый PCM. WebM/Opus — сжатый кодек. Запись 2-3 секунды: WebM ~15-20KB, WAV ~60-70KB.
|
|
||||||
|
|
||||||
**Решение (v34):** Полностью удалили `blobToWav()`. Отправляем нативный `audio/webm` blob, полученный от `MediaRecorder`.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// БЫЛО (v33, сломано):
|
|
||||||
let wavBlob = await blobToWav(blob);
|
|
||||||
formData.append('file', new File([wavBlob], 'audio.wav', { type: 'audio/wav' }));
|
|
||||||
|
|
||||||
// СТАЛО (v34+, правильно):
|
|
||||||
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 4: Двойной CORS-заголовок «*, *»
|
|
||||||
|
|
||||||
**Симптом:** Браузер блокировал ответ: «The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed». `transcribe()` висел 19.87с.
|
|
||||||
|
|
||||||
**Причина:** И nginx (`add_header Access-Control-Allow-Origin "*"`), и Flask (в `CORS` dict) добавляли одинаковый заголовок. nginx добавлял СВОЙ поверх ответа Flask → в ответе оказывалось два заголовка `Access-Control-Allow-Origin: *`.
|
|
||||||
|
|
||||||
**Решение:** Убрали CORS-заголовки из nginx. Flask сам отдаёт правильный CORS.
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
# БЫЛО (в nginx):
|
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
|
||||||
|
|
||||||
# СТАЛО: ничего, Flask сам управляет CORS
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 5: POST-запросы >16KB не доходят из России в Германию
|
|
||||||
|
|
||||||
**Симптом:**
|
|
||||||
- OPTIONS (0 байт): 0.3с ✅
|
|
||||||
- POST 3-16KB: 0.3-0.4с ✅
|
|
||||||
- POST 24KB+: 80% таймаутов (HTTP:000, 10-30с)
|
|
||||||
- POST 64KB: 100% таймаутов
|
|
||||||
|
|
||||||
**Причина:** Комбинация факторов:
|
|
||||||
1. DPI/шeйпинг — российский провайдер может резать HTTPS POST с телом > некоторого порога
|
|
||||||
2. VPN hairpin — если VPN включён глобально, трафик на proxy.kube5s.ru идёт через туннель обратно на тот же сервер, TCP-буферы не справляются
|
|
||||||
3. nginx буферизация — nginx пишет тело в `/var/cache/nginx/client_temp/` (лог: «client request body is buffered to a temporary file»)
|
|
||||||
|
|
||||||
**Исследование:**
|
|
||||||
- SCP 64KB работает (2.1с, 338KB/s) — чистый TCP через VPN
|
|
||||||
- SSH-туннель в обход nginx → gunicorn: 0.6с
|
|
||||||
- HTTP/2, HTTP без TLS, без Expect — не помогли
|
|
||||||
- `client_body_buffer_size 256k`, `proxy_request_buffering off` — не помогли
|
|
||||||
|
|
||||||
**Решение (v43): WebSocket для файлов >14KB.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 6: Чанки через HTTP POST не работают
|
|
||||||
|
|
||||||
**Симптом:** `/upload/start` возвращал HTTP:000 (тот же сетевой таймаут).
|
|
||||||
|
|
||||||
**Причина:** Каждый POST-запрос страдает от той же проблемы (DPI/hairpin/буферизация), независимо от размера тела.
|
|
||||||
|
|
||||||
**Вывод:** HTTP POST — тупиковый путь. Нужен другой протокол.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Решение: WebSocket (v43)
|
|
||||||
|
|
||||||
**Идея:** WebSocket после HTTP Upgrade становится чистым TCP-туннелем с минимальным фреймингом. nginx не буферизует WebSocket-фреймы — только проксирует байты туда-обратно.
|
|
||||||
|
|
||||||
**Реализация:**
|
|
||||||
|
|
||||||
### Сервер: `/opt/groq-proxy/ws_server.py`
|
|
||||||
- Порт 8766 (только localhost)
|
|
||||||
- Принимает JSON-сообщение: `{"token": "...", "chunks": N}`
|
|
||||||
- Принимает N бинарных сообщений (чанки по 4KB)
|
|
||||||
- Собирает, отправляет multipart в Groq
|
|
||||||
- Возвращает JSON с результатом
|
|
||||||
|
|
||||||
### Клиент: `transcribe()` в index.html
|
|
||||||
- Если blob ≤ 14KB: прямой POST (как раньше)
|
|
||||||
- Если blob > 14KB: WebSocket `wss://proxy.kube5s.ru/ws`
|
|
||||||
- Шлёт контрольное JSON-сообщение
|
|
||||||
- Шлёт бинарные чанки по 4KB (`blob.slice()` + `chunk.arrayBuffer()`)
|
|
||||||
- Получает JSON-ответ
|
|
||||||
|
|
||||||
### Nginx: `/ws` location
|
|
||||||
```nginx
|
|
||||||
location /ws {
|
|
||||||
proxy_pass http://127.0.0.1:8766;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
proxy_send_timeout 120s;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Systemd: `groq-ws.service`
|
|
||||||
- `KillSignal=SIGKILL` — обязательно! Старый процесс asyncio не умирает от SIGTERM
|
|
||||||
- `KillMode=process`
|
|
||||||
- `Restart=always`
|
|
||||||
|
|
||||||
**Результат:**
|
|
||||||
- 64KB с немецкого сервера: 0.34с
|
|
||||||
- 64KB с России (локальная машина): 0.67с
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 7: WebSocket-сервер падал каждую секунду
|
|
||||||
|
|
||||||
**Симптом:** Systemd бесконечно перезапускал `groq-ws.service` (restart counter = 35+). Браузер иногда подключался (HTTP 101), иногда получал 502.
|
|
||||||
|
|
||||||
**Причина:** При `systemctl restart` старый asyncio-процесс не убивался SIGTERM. Порт 8766 оставался занят. Новый процесс падал с `OSError: [Errno 98] address already in use`.
|
|
||||||
|
|
||||||
**Решение:** `KillSignal=SIGKILL` в systemd unit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка 8: Параллельные анализы (isAnalyzing flag, v39)
|
|
||||||
|
|
||||||
**Симптом:** Повторные клики на Сравнить/Стерео запускали параллельные `doCompare()`, множили сетевые запросы.
|
|
||||||
|
|
||||||
**Решение:** Флаг `isAnalyzing = true` в начале `doCompare()`, сброс в `false` после завершения или ошибки. Повторные вызовы игнорируются:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function doCompare(blob, originalText, duration) {
|
|
||||||
if (isAnalyzing) return '';
|
|
||||||
isAnalyzing = true;
|
|
||||||
// ...
|
|
||||||
isAnalyzing = false;
|
|
||||||
return whisperResult.text || '';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Файлы и их расположение
|
|
||||||
|
|
||||||
### На немецком сервере (95.179.252.111)
|
|
||||||
| Файл | Назначение |
|
|
||||||
|------|------------|
|
|
||||||
| `/opt/groq-proxy/proxy.py` | Flask-прокси (прямые POST ≤14KB) |
|
|
||||||
| `/opt/groq-proxy/ws_server.py` | WebSocket-сервер (файлы >14KB) |
|
|
||||||
| `/etc/nginx/conf.d/groq-proxy.conf` | nginx: TLS, /ws, / |
|
|
||||||
| `/etc/systemd/system/groq-proxy.service` | gunicorn (4 воркера, порт 8765, timeout 120s) |
|
|
||||||
| `/etc/systemd/system/groq-ws.service` | WebSocket server (порт 8766, SIGKILL) |
|
|
||||||
| `/tmp/groq_key.txt` | Groq API ключ (для тестов) |
|
|
||||||
|
|
||||||
### Локально
|
|
||||||
| Файл | Назначение |
|
|
||||||
|------|------------|
|
|
||||||
| `~/lang/index.html` | SPA приложение (v43, WebSocket + прямой POST) |
|
|
||||||
| `~/lang/deploy_lang.sh` | Скрипт деплоя в K8s (configmap + rollout) |
|
|
||||||
| `~/lang/token.txt` | Groq API ключ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
|
|
||||||
### Деплой index.html в K8s
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/lang && bash deploy_lang.sh
|
cd ~/lang && bash deploy_lang.sh
|
||||||
|
# → build.sh склеивает CSS/JS → dist/index.html
|
||||||
|
# → scp на 95.179.252.111:/var/www/lyngvo/index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
### Деплой прокси на немецкий сервер
|
## Стек
|
||||||
```bash
|
|
||||||
# Flask proxy
|
|
||||||
scp -i ~/.ssh/vultr_openssh /tmp/groq_proxy.py root@95.179.252.111:/opt/groq-proxy/proxy.py
|
|
||||||
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-proxy'
|
|
||||||
|
|
||||||
# WebSocket server
|
- Фронтенд: HTML/CSS/JS (без фреймворков), MediaRecorder API, Web Audio API
|
||||||
scp -i ~/.ssh/vultr_openssh /tmp/ws_server.py root@95.179.252.111:/opt/groq-proxy/ws_server.py
|
- API: Groq Whisper (транскрипция) + Groq Llama (перевод)
|
||||||
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-ws'
|
- Транспорт: HTTPS POST multipart/form-data (DPI обойдён)
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка статуса
|
## DPI
|
||||||
```bash
|
|
||||||
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 \
|
|
||||||
'systemctl is-active groq-proxy groq-ws nginx && ss -tlnp | grep -E "8765|8766"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тест WebSocket с локальной машины
|
РКН режет WebSocket. HTTPS POST multipart/form-data до 150KB проходит.
|
||||||
```bash
|
Подробнее: doc/ws-dpi-solution.md
|
||||||
cd ~/lang && .venv/bin/python3 -c "
|
|
||||||
import asyncio, websockets, json
|
|
||||||
async def t():
|
|
||||||
ws = await websockets.connect('wss://proxy.kube5s.ru/ws')
|
|
||||||
await ws.send(json.dumps({'token': '$(cat token.txt)', 'chunks': 1}))
|
|
||||||
with open('/tmp/test.wav','rb') as f: await ws.send(f.read())
|
|
||||||
print(await ws.recv())
|
|
||||||
asyncio.run(t())
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Версии index.html
|
|
||||||
|
|
||||||
| Версия | Изменения |
|
|
||||||
|--------|-----------|
|
|
||||||
| v33 | ❌ Добавлен blobToWav — сломано |
|
|
||||||
| v34 | ✅ Удалён blobToWav, прямой WebM |
|
|
||||||
| v35 | Таймаут увеличен до 60с |
|
|
||||||
| v36 | Слоги после 📊, 🗑 в конце строки |
|
|
||||||
| v37 | Дебаг-логи в консоль |
|
|
||||||
| v38 | Лог размера блоба |
|
|
||||||
| v39 | isAnalyzing флаг, try/catch в doCompare |
|
|
||||||
| v40 | Чистка дебаг-логов |
|
|
||||||
| v41 | Лог [WHISPER] в консоль |
|
|
||||||
| v42 | ❌ Чанки через HTTP POST (не сработало) |
|
|
||||||
| v43 | ✅ WebSocket для файлов >14KB |
|
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Обход DPI РКН для передачи аудио на Groq Whisper
|
||||||
|
|
||||||
|
**Дата:** 2026-05-22
|
||||||
|
**Итог:** WebSocket — тупик. Рабочее решение — `fetch POST` с `multipart/form-data`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Результаты тестов DPI (Россия -> Германия, lang.kube5s.ru)
|
||||||
|
|
||||||
|
| Метод | Размер | Результат |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| HTTP POST с JSON-телом | 40KB JSON | ✅ |
|
||||||
|
| HTTP POST с JSON-телом | 133KB JSON | ✅ |
|
||||||
|
| HTTP POST с JSON-телом | 200KB JSON | ⚠️ погранично |
|
||||||
|
| HTTP POST multipart/form-data | 30-150KB blob | ✅ все |
|
||||||
|
| WebSocket text-фрейм | <=45KB JSON | ✅ |
|
||||||
|
| WebSocket text-фрейм | >45KB JSON | ❌ |
|
||||||
|
| WebSocket чанки по 14KB + задержки | любой | ❌ |
|
||||||
|
| WebSocket binary-фрейм | любой | ❌ |
|
||||||
|
|
||||||
|
**Вывод:** DPI целенаправленно блокирует WebSocket-трафик. Обычный HTTPS POST проходит до ~130KB JSON или 150KB multipart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рабочее решение (v62+)
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер (РФ) lang.kube5s.ru (Германия) api.groq.com
|
||||||
|
| | |
|
||||||
|
| POST /openai/v1/audio/ | |
|
||||||
|
| transcriptions | |
|
||||||
|
| Content-Type: multipart/ | |
|
||||||
|
| form-data | |
|
||||||
|
| Authorization: Bearer KEY | |
|
||||||
|
| file=<blob.webm> | |
|
||||||
|
| model=whisper-large-v3 | |
|
||||||
|
| language=it ---------------->| |
|
||||||
|
| | прокси пробрасывает ------>|
|
||||||
|
| | Whisper
|
||||||
|
| | <------- JSON {text} -----|
|
||||||
|
| <------- JSON {text} --------| |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые моменты
|
||||||
|
|
||||||
|
1. Никаких WebSocket — DPI их палит
|
||||||
|
2. Никакого base64 — Blob идёт как есть в multipart
|
||||||
|
3. Никакого JSON для аудио — бинарные данные в FormData
|
||||||
|
4. Сервер НЕ менялся — универсальный Flask-прокси форвардит всё в Groq
|
||||||
|
|
||||||
|
### Клиент (index.html v62)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function transcribe(blob) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'audio.webm');
|
||||||
|
form.append('model', 'whisper-large-v3');
|
||||||
|
form.append('language', 'it');
|
||||||
|
|
||||||
|
const r = await fetch(
|
||||||
|
'https://lang.kube5s.ru/openai/v1/audio/transcriptions',
|
||||||
|
{ method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
||||||
|
body: form }
|
||||||
|
);
|
||||||
|
const data = await r.json();
|
||||||
|
return { text: data.text || '', error: data.error ? data.error : null };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервер (proxy.py) — без изменений
|
||||||
|
|
||||||
|
Универсальный прокси: любой метод, любой путь -> api.groq.com.
|
||||||
|
Пробрасывает Authorization и Content-Type, стримит ответ.
|
||||||
|
|
||||||
|
### Nginx (groq-proxy.conf)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8765;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тупиковые подходы
|
||||||
|
|
||||||
|
### WebSocket + base64 (v45-v61)
|
||||||
|
|
||||||
|
Идея: кодировать аудио в base64, отправлять текстовыми WS-фреймами.
|
||||||
|
|
||||||
|
Почему провалилось:
|
||||||
|
- Одиночный фрейм до ~45KB JSON проходил, больше — нет
|
||||||
|
- Чанки по 14-30KB с задержками — DPI обрывал соединение на ~3-4 фрейме
|
||||||
|
- Задержки между фреймами (150-200мс) не помогали
|
||||||
|
- Результат хаотичный: 3 фрейма ОК -> 4 таймаут -> 5 ОК -> 8 таймаут
|
||||||
|
- DPI детектит WebSocket-паттерн и режет независимо от содержимого
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фонетический анализ: таймаут decodeAudioData
|
||||||
|
|
||||||
|
decodeAudioData в Chrome может навсегда зависнуть на WebM/Opus >2с (баг Chrome, не DPI).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const phonTimeout = new Promise(resolve =>
|
||||||
|
setTimeout(() => resolve({ pitchHz:0, pitchStability:0, voiceClarity:0, phonScore:0 }), 15000)
|
||||||
|
);
|
||||||
|
[whisperResult, phon] = await Promise.all([
|
||||||
|
transcribe(blob),
|
||||||
|
Promise.race([phoneticAnalysis(blob), phonTimeout])
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
При таймауте — фонетика показывает нули, транскрипция работает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файлы и деплой
|
||||||
|
|
||||||
|
```
|
||||||
|
Сервер (Vultr 95.179.252.111):
|
||||||
|
/opt/groq-proxy/proxy.py — HTTP прокси (Flask/gunicorn :8765) ОСНОВНОЙ
|
||||||
|
/opt/groq-proxy/ws_server.py — WS сервер (:8766) БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ
|
||||||
|
/etc/nginx/conf.d/groq-proxy.conf
|
||||||
|
|
||||||
|
Клиент (K8s lang.kube5s.ru):
|
||||||
|
~/lang/index.html — SPA
|
||||||
|
~/lang/deploy_lang.sh — деплой
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111
|
||||||
|
cd ~/lang && bash deploy_lang.sh
|
||||||
|
systemctl restart groq-http
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хронология версий
|
||||||
|
|
||||||
|
| Версия | Подход | Результат |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| v34-v44 | WAV->WebM, HTTP chunked | ❌ |
|
||||||
|
| v45-v47 | WS binary, чанки base64 | ❌ |
|
||||||
|
| v48 | WS один JSON base64 (~30KB) | ✅ короткие |
|
||||||
|
| v49-v53 | Фиксы WS race condition | ❌ откачены |
|
||||||
|
| v54-v59 | Чанки base64 в WS | ❌ |
|
||||||
|
| v60-v61 | Чанки 14KB + задержки | ❌ |
|
||||||
|
| **v62** | **fetch POST multipart/form-data** | **✅ РАБОТАЕТ** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестовые скрипты (в репозитории)
|
||||||
|
|
||||||
|
| Файл | Что тестирует |
|
||||||
|
|------|---------------|
|
||||||
|
| test_http.py | HTTP POST JSON разных размеров |
|
||||||
|
| test_multipart.py | HTTP POST multipart/form-data |
|
||||||
|
| test_chunks.py | WS чанки с задержками |
|
||||||
|
| test_threshold.py | Порог DPI для кол-ва WS фреймов |
|
||||||
|
| test_hello.py | Минимальный WS: connect + 1 сообщение |
|
||||||
|
| test_single.py | Один большой WS фрейм |
|
||||||
+11
-730
@@ -7,27 +7,13 @@
|
|||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>Lyngvo — Итальянский тренажёр произношения</title>
|
<title>Lyngvo — Итальянский тренажёр произношения</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<link rel="stylesheet" href="css/style.css">
|
||||||
body { background: #f8f8fa; font-family: sans-serif; }
|
|
||||||
#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; }
|
|
||||||
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; }
|
|
||||||
#score { font-size:1.5em; margin:12px 0; }
|
|
||||||
#diff { font-size:1.2em; line-height:1.8; }
|
|
||||||
#history { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
|
|
||||||
#history span { font-size:0.85em; background:#e8e8f0; padding:3px 10px; border-radius:12px; cursor:pointer; }
|
|
||||||
#history span:hover { background:#d0d0e0; }
|
|
||||||
.phonetics { font-size:0.85em; color:#666; margin-top:6px; display:flex; gap:16px; flex-wrap:wrap; }
|
|
||||||
.recItem { display:flex; align-items:center; gap:8px; padding:4px 8px; background:#f0f0f5; border-radius:8px; margin:4px 0; }
|
|
||||||
.recItem button { font-size:0.8em; padding:4px 10px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<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>
|
<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.8em;color:#666;margin-bottom:14px;white-space:nowrap">
|
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
|
||||||
1. Введите фразу → 2. ▶ Эталон (прослушайте) → 3. 🎙 Записать (произнесите) → 4. 📊 Сравнить
|
1. Введите фразу → 2. ▶ Эталон (прослушайте) → 3. 🎙 Записать (сделайте короткую паузу перед произнесением) → 4. 📊 Сравнить
|
||||||
</div>
|
</div>
|
||||||
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
||||||
<div id="history"></div>
|
<div id="history"></div>
|
||||||
@@ -43,724 +29,19 @@
|
|||||||
<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="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>
|
||||||
<div id="recHistory" style="margin-top:16px;font-size:0.85em"></div>
|
<div id="recHistory" style="margin-top:16px;font-size:0.85em"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
const GROQ_API_KEY = '';
|
|
||||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
|
||||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
|
||||||
let recordStartTime = 0, recordDuration = 0;
|
|
||||||
// ---------- Версия ----------
|
|
||||||
const VERSION = 'v45';
|
|
||||||
document.getElementById('ver').textContent = VERSION;
|
|
||||||
|
|
||||||
// ---------- Перевод на русский (Groq) ----------
|
<script src="js/storage.js"></script>
|
||||||
async function translateToRussian(text) {
|
<script src="js/syllables.js"></script>
|
||||||
if (!GROQ_API_KEY) return;
|
<script src="js/transcribe.js"></script>
|
||||||
try {
|
<script src="js/pronounce.js"></script>
|
||||||
const res = await fetch('https://proxy.kube5s.ru/openai/v1/chat/completions', {
|
<script src="js/audio.js"></script>
|
||||||
method: 'POST',
|
<script src="js/compare.js"></script>
|
||||||
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
<script src="js/main.js"></script>
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'llama-3.3-70b-versatile',
|
|
||||||
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
|
||||||
max_tokens: 50,
|
|
||||||
temperature: 0
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.choices?.[0]?.message?.content) {
|
|
||||||
document.getElementById('translation').textContent = '🇷🇺 ' + data.choices[0].message.content;
|
|
||||||
}
|
|
||||||
} catch(e) { /* тихо */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- История записей (IndexedDB) ----------
|
|
||||||
function openRecDB() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = indexedDB.open('lyngvo_recs', 1);
|
|
||||||
req.onupgradeneeded = () => { req.result.createObjectStore('recs', { keyPath: 'id', autoIncrement: true }); };
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
const db = await openRecDB();
|
|
||||||
const tx = db.transaction('recs', 'readwrite');
|
|
||||||
const store = tx.objectStore('recs');
|
|
||||||
store.add({ word, duration, blob, ts: Date.now() });
|
|
||||||
// Удаляем старые если > 10
|
|
||||||
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
|
||||||
if (all.length > 10) {
|
|
||||||
const toDelete = all.sort((a,b) => a.ts - b.ts).slice(0, all.length - 10);
|
|
||||||
for (const rec of toDelete) store.delete(rec.id);
|
|
||||||
}
|
|
||||||
await new Promise(r => { tx.oncomplete = r; });
|
|
||||||
renderRecHistory();
|
|
||||||
} catch(e) { /* тихо */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRecordings() {
|
|
||||||
try {
|
|
||||||
const db = await openRecDB();
|
|
||||||
const tx = db.transaction('recs', 'readonly');
|
|
||||||
const store = tx.objectStore('recs');
|
|
||||||
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
|
||||||
return all.sort((a,b) => b.ts - a.ts);
|
|
||||||
} catch(e) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRecHistory() {
|
|
||||||
const recs = await loadRecordings();
|
|
||||||
const div = document.getElementById('recHistory');
|
|
||||||
if (!recs.length) { div.innerHTML = ''; return; }
|
|
||||||
div.innerHTML = '<div style="color:#999;margin-bottom:4px">📼 История записей (выберите для стерео):</div>' +
|
|
||||||
recs.map(r => {
|
|
||||||
const d = new Date(r.ts);
|
|
||||||
const time = d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
|
||||||
return '<div class="recItem">' +
|
|
||||||
'<span style="cursor:pointer" onclick="document.getElementById(\'inputText\').value=\'' +
|
|
||||||
r.word.replace(/'/g, "\\'") + '\';ttsText=\'' + r.word.replace(/'/g, "\\'") + '\'">' + r.word + '</span>' +
|
|
||||||
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
|
|
||||||
'<button onclick="playRec(' + 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>';
|
|
||||||
}).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) {
|
|
||||||
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;
|
|
||||||
await playStereo(rec.word, rec.blob);
|
|
||||||
// после стерео — сравнение
|
|
||||||
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
|
||||||
if (tr) saveTranscription(id, tr);
|
|
||||||
}
|
|
||||||
} 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);
|
|
||||||
let whisperResult, phon;
|
|
||||||
try {
|
|
||||||
[whisperResult, phon] = await Promise.all([
|
|
||||||
transcribe(blob),
|
|
||||||
phoneticAnalysis(blob)
|
|
||||||
]);
|
|
||||||
} 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) {
|
|
||||||
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) {
|
|
||||||
const url = URL.createObjectURL(rec.blob);
|
|
||||||
const audio = new Audio(url);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
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) { /* тихо */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- История фраз (localStorage) ----------
|
|
||||||
const HIST_KEY = 'lyngvo_phrases';
|
|
||||||
function loadHistory() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; }
|
|
||||||
catch { return []; }
|
|
||||||
}
|
|
||||||
function saveToHistory(text) {
|
|
||||||
let hist = loadHistory().filter(t => t !== text);
|
|
||||||
hist.unshift(text);
|
|
||||||
hist = hist.slice(0, 5);
|
|
||||||
localStorage.setItem(HIST_KEY, JSON.stringify(hist));
|
|
||||||
renderHistory();
|
|
||||||
}
|
|
||||||
function renderHistory() {
|
|
||||||
const hist = loadHistory();
|
|
||||||
const div = document.getElementById('history');
|
|
||||||
if (!hist.length) { div.innerHTML = ''; return; }
|
|
||||||
div.innerHTML = hist.map(t =>
|
|
||||||
'<span onclick="var v=\'' + t.replace(/'/g, "\\'") +
|
|
||||||
'\';document.getElementById(\'inputText\').value=v;ttsText=v;renderHistory()">' + t + '</span>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
renderHistory();
|
|
||||||
renderRecHistory();
|
|
||||||
|
|
||||||
// ---------- Фонетический анализ ----------
|
|
||||||
function phoneticAnalysis(blob) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (e) => {
|
|
||||||
try {
|
|
||||||
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
|
|
||||||
const buf = await ctx.decodeAudioData(e.target.result);
|
|
||||||
const data = buf.getChannelData(0);
|
|
||||||
const sr = buf.sampleRate;
|
|
||||||
const len = data.length;
|
|
||||||
|
|
||||||
const frameSize = Math.floor(sr * 0.025); // 25ms
|
|
||||||
const step = Math.floor(frameSize / 2); // 12.5ms, 50% overlap
|
|
||||||
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 = [];
|
|
||||||
let voicedCount = 0;
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Артикуляция — ZCR voiced-кадров (чёткость согласных)
|
|
||||||
const vzArr = [];
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) {
|
|
||||||
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
|
||||||
}
|
|
||||||
let articulationScore = 40;
|
|
||||||
if (vzArr.length > 0) {
|
|
||||||
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
|
||||||
// Итальянская речь: 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% записи)
|
|
||||||
// Итальянские слова заканчиваются на гласную → конец должен быть voiced
|
|
||||||
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 clarityScore = Math.round(
|
|
||||||
voiceRatio < 0.05 ? 0 :
|
|
||||||
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Итоговый балл
|
|
||||||
const hasSpeech = voiceRatio > 0.08;
|
|
||||||
const phonScore = hasSpeech ? Math.round(
|
|
||||||
pitchStability * 0.30 +
|
|
||||||
articulationScore * 0.25 +
|
|
||||||
clarityScore * 0.25 +
|
|
||||||
endingScore * 0.20
|
|
||||||
) : 0;
|
|
||||||
|
|
||||||
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
|
||||||
} catch (err) {
|
|
||||||
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Нормированная автокорреляция для одного кадра
|
|
||||||
function pitchFrame(data, start, len, sr) {
|
|
||||||
const n = len;
|
|
||||||
let e0 = 0;
|
|
||||||
for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i];
|
|
||||||
if (e0 < 1e-8) return 0;
|
|
||||||
|
|
||||||
const minLag = Math.floor(sr / 400);
|
|
||||||
const maxLag = Math.min(n - 1, Math.floor(sr / 70));
|
|
||||||
let bestR = 0, bestLag = 0;
|
|
||||||
|
|
||||||
for (let lag = minLag; lag <= maxLag; lag++) {
|
|
||||||
let num = 0, e1 = 0, e2 = 0;
|
|
||||||
const m = n - lag;
|
|
||||||
for (let i = 0; i < m; i++) {
|
|
||||||
const a = data[start + i], b = data[start + i + lag];
|
|
||||||
num += a * b;
|
|
||||||
e1 += a * a;
|
|
||||||
e2 += b * b;
|
|
||||||
}
|
|
||||||
const den = Math.sqrt(e1 * e2);
|
|
||||||
const r = den > 0 ? num / den : 0;
|
|
||||||
if (r > bestR) { bestR = r; bestLag = lag; }
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestR > 0.30 ? sr / bestLag : 0; // порог (energy pre-filter снижает ложные срабатывания)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
|
||||||
function enable(id, val) { document.getElementById(id).disabled = !val; }
|
|
||||||
|
|
||||||
// ---------- Индикатор микрофона ----------
|
|
||||||
function startMicMeter(stream) {
|
|
||||||
try {
|
|
||||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
const src = ctx.createMediaStreamSource(stream);
|
|
||||||
micAnalyser = ctx.createAnalyser();
|
|
||||||
micAnalyser.fftSize = 256;
|
|
||||||
src.connect(micAnalyser);
|
|
||||||
const bar = document.getElementById('micBar');
|
|
||||||
const fill = document.getElementById('micFill');
|
|
||||||
bar.style.display = 'block';
|
|
||||||
fill.style.width = '0%';
|
|
||||||
const data = new Uint8Array(micAnalyser.frequencyBinCount);
|
|
||||||
function tick() {
|
|
||||||
if (!micAnalyser) return;
|
|
||||||
micAnalyser.getByteFrequencyData(data);
|
|
||||||
const max = Math.max(...data);
|
|
||||||
const pct = Math.min(100, Math.round(max / 255 * 100));
|
|
||||||
fill.style.width = pct + '%';
|
|
||||||
fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50';
|
|
||||||
micAnimId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
tick();
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('Mic meter:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopMicMeter() {
|
|
||||||
if (micAnimId) cancelAnimationFrame(micAnimId);
|
|
||||||
micAnimId = null;
|
|
||||||
micAnalyser = null;
|
|
||||||
document.getElementById('micBar').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- TTS ----------
|
|
||||||
function playTTS(text) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const utter = new SpeechSynthesisUtterance(text);
|
|
||||||
utter.lang = 'it-IT';
|
|
||||||
utter.rate = 0.9;
|
|
||||||
const voices = speechSynthesis.getVoices();
|
|
||||||
const it = voices.find(v => v.lang.startsWith('it'));
|
|
||||||
if (it) utter.voice = it;
|
|
||||||
utter.onend = resolve;
|
|
||||||
utter.onerror = resolve;
|
|
||||||
speechSynthesis.cancel();
|
|
||||||
speechSynthesis.speak(utter);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Запись ----------
|
|
||||||
async function startRecording() {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
// Индикатор запускаем ПОСЛЕ получения stream
|
|
||||||
startMicMeter(stream);
|
|
||||||
audioChunks = [];
|
|
||||||
// Проверяем поддержку кодеков
|
|
||||||
let mimeType = 'audio/webm;codecs=opus';
|
|
||||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
|
||||||
mimeType = 'audio/webm';
|
|
||||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
|
||||||
mimeType = ''; // default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const opts = mimeType ? { mimeType } : {};
|
|
||||||
mediaRecorder = new MediaRecorder(stream, opts);
|
|
||||||
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
stopMicMeter();
|
|
||||||
recordDuration = (Date.now() - recordStartTime) / 1000;
|
|
||||||
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
|
|
||||||
stream.getTracks().forEach(t => t.stop());
|
|
||||||
enable('btnCompare', true);
|
|
||||||
enable('btnStereo', true);
|
|
||||||
enable('btnPlayUser', true);
|
|
||||||
const btn = document.getElementById('btnRecord');
|
|
||||||
btn.textContent = '🎙 Записать';
|
|
||||||
btn.disabled = false;
|
|
||||||
isRecording = false;
|
|
||||||
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
|
|
||||||
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
|
|
||||||
else {
|
|
||||||
const t = document.getElementById('inputText').value.trim();
|
|
||||||
if (t) saveRecording(userAudioBlob, t, recordDuration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
mediaRecorder.start(100); // собираем чанки каждые 100ms
|
|
||||||
recordStartTime = Date.now();
|
|
||||||
setStatus('Идёт запись...');
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
||||||
mediaRecorder.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Кнопка Запись ----------
|
|
||||||
document.getElementById('btnRecord').onclick = async function () {
|
|
||||||
const btn = this;
|
|
||||||
if (isRecording) {
|
|
||||||
btn.disabled = true;
|
|
||||||
stopRecording();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
isRecording = true;
|
|
||||||
btn.textContent = '■ Стоп';
|
|
||||||
await startRecording();
|
|
||||||
} catch (err) {
|
|
||||||
let msg = '⛔ ';
|
|
||||||
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Нажмите на замочек 🔒 в адресной строке → Микрофон → Разрешить.';
|
|
||||||
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден. Подключите микрофон.';
|
|
||||||
else if (err.name === 'NotReadableError') msg += 'Микрофон занят другим приложением.';
|
|
||||||
else msg += 'Ошибка: ' + (err.message || err.name);
|
|
||||||
setStatus(msg);
|
|
||||||
btn.textContent = '🎙 Записать';
|
|
||||||
isRecording = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- Воспроизведение своей записи ----------
|
|
||||||
document.getElementById('btnPlayUser').onclick = async () => {
|
|
||||||
if (!userAudioBlob) return setStatus('Нет записи!');
|
|
||||||
if (userAudioBlob.size === 0) return setStatus('Запись пустая!');
|
|
||||||
const url = URL.createObjectURL(userAudioBlob);
|
|
||||||
const audio = new Audio(url);
|
|
||||||
audio.volume = 1.0;
|
|
||||||
audio.onended = () => { URL.revokeObjectURL(url); setStatus('Готово.'); };
|
|
||||||
audio.onerror = () => { URL.revokeObjectURL(url); setStatus('Ошибка воспроизведения.'); };
|
|
||||||
setStatus('🔊 Ваша запись...');
|
|
||||||
try {
|
|
||||||
await audio.play();
|
|
||||||
} catch (err) {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
setStatus('Ошибка воспроизведения — нажмите ещё раз.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// после воспроизведения — сравнение
|
|
||||||
const original = document.getElementById('inputText').value.trim();
|
|
||||||
if (original && userAudioBlob) {
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function transcribe(blob) {
|
|
||||||
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
|
|
||||||
const tStart = performance.now();
|
|
||||||
|
|
||||||
// Small file: direct POST
|
|
||||||
if (blob.size <= 14000) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
|
|
||||||
formData.append('model', 'whisper-large-v3');
|
|
||||||
formData.append('language', 'it');
|
|
||||||
try {
|
|
||||||
const ctl = new AbortController();
|
|
||||||
const t = setTimeout(() => ctl.abort(), 60000);
|
|
||||||
const res = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', {
|
|
||||||
method: 'POST', headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
|
||||||
body: formData, signal: ctl.signal
|
|
||||||
});
|
|
||||||
clearTimeout(t);
|
|
||||||
const data = await res.json();
|
|
||||||
console.log('[WHISPER] ' + (blob.size/1024).toFixed(1) + 'KB direct ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
|
||||||
if (data.error) return { text: '', error: data.error.message || 'forbidden' };
|
|
||||||
return { text: data.text || '', error: null };
|
|
||||||
} catch (err) {
|
|
||||||
return { text: '', error: err.name === 'AbortError' ? 'timeout' : 'network' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Large file: WebSocket (entire blob, no chunks)
|
|
||||||
console.log('[WS] opening, blob=' + (blob.size/1024).toFixed(1) + 'KB');
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const ws = new WebSocket('wss://proxy.kube5s.ru/ws');
|
|
||||||
const timeout = setTimeout(() => { console.log('[WS] timeout'); ws.close(); resolve({ text: '', error: 'timeout' }); }, 60000);
|
|
||||||
|
|
||||||
ws.onopen = async () => {
|
|
||||||
console.log('[WS] connected');
|
|
||||||
ws.send(JSON.stringify({ token: GROQ_API_KEY }));
|
|
||||||
ws.send(await blob.arrayBuffer());
|
|
||||||
console.log('[WS] blob sent');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ws.close();
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
console.log('[WHISPER] ' + (blob.size/1024).toFixed(1) + 'KB ws ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
|
||||||
if (data.error) resolve({ text: '', error: data.error });
|
|
||||||
else resolve({ text: data.text || '', error: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
console.log('[WS] error');
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve({ text: '', error: 'network' });
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
resolve({ text: '', error: 'network' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(text) {
|
|
||||||
return text.toLowerCase().replace(/[.,!?;:«»"'’]/g, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Нечёткое сравнение (Левенштейн) ----------
|
|
||||||
function levenshtein(a, b) {
|
|
||||||
const m = a.length, n = b.length;
|
|
||||||
const dp = Array.from({length: m+1}, (_,i) => [i]);
|
|
||||||
for (let j=0; j<=n; j++) dp[0][j] = j;
|
|
||||||
for (let i=1; i<=m; i++)
|
|
||||||
for (let j=1; j<=n; j++)
|
|
||||||
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
|
|
||||||
return dp[m][n];
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordSimilarity(a, b) {
|
|
||||||
// 0..1 где 1 = идентично
|
|
||||||
const maxLen = Math.max(a.length, b.length);
|
|
||||||
if (maxLen === 0) return 1;
|
|
||||||
const dist = levenshtein(a, b);
|
|
||||||
return 1 - dist / maxLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareTexts(original, transcribed) {
|
|
||||||
const a = normalize(original).split(/\s+/);
|
|
||||||
const b = normalize(transcribed).split(/\s+/);
|
|
||||||
let totalScore = 0;
|
|
||||||
const result = a.map(word => {
|
|
||||||
let best = 0, bestMatch = '';
|
|
||||||
for (const t of b) {
|
|
||||||
const sim = wordSimilarity(word, t);
|
|
||||||
if (sim > best) { best = sim; bestMatch = t; }
|
|
||||||
}
|
|
||||||
totalScore += best;
|
|
||||||
// ok=зелёный (≥80%), near=жёлтый (≥50%), bad=красный
|
|
||||||
const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad';
|
|
||||||
return { word, quality, bestMatch, score: Math.round(best*100) };
|
|
||||||
});
|
|
||||||
const score = Math.round((totalScore / a.length) * 100);
|
|
||||||
return { score, words: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDiff(words) {
|
|
||||||
return words.map(({ word, quality, bestMatch, score }) => {
|
|
||||||
const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red';
|
|
||||||
const tip = quality !== 'ok' ? ` → ${bestMatch} (${score}%)` : '';
|
|
||||||
return `<span style="color:${color}; font-weight:bold">${word}</span><span style="color:#888; font-size:0.85em">${tip}</span>`;
|
|
||||||
}).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playStereo(ttsText, userBlob) {
|
|
||||||
// Простой вариант: параллельно TTS и user audio, pan через Web Audio API
|
|
||||||
const ctx = new AudioContext();
|
|
||||||
// User audio
|
|
||||||
const userURL = URL.createObjectURL(userBlob);
|
|
||||||
const userAudio = new Audio(userURL);
|
|
||||||
const userSource = ctx.createMediaElementSource(userAudio);
|
|
||||||
const userPan = ctx.createStereoPanner();
|
|
||||||
userPan.pan.value = 1;
|
|
||||||
userSource.connect(userPan).connect(ctx.destination);
|
|
||||||
// TTS
|
|
||||||
await playTTS(ttsText);
|
|
||||||
userAudio.play();
|
|
||||||
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btnTTS').onclick = async () => {
|
|
||||||
ttsText = document.getElementById('inputText').value.trim();
|
|
||||||
if (!ttsText) return setStatus('Введите текст!');
|
|
||||||
saveToHistory(ttsText);
|
|
||||||
translateToRussian(ttsText);
|
|
||||||
setStatus('🔊 Эталон...');
|
|
||||||
await playTTS(ttsText);
|
|
||||||
setStatus('Готово.');
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('btnCompare').onclick = async () => {
|
|
||||||
if (!userAudioBlob) return setStatus('Нет записи!');
|
|
||||||
const original = document.getElementById('inputText').value.trim();
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
};
|
|
||||||
document.getElementById('btnStereo').onclick = async () => {
|
|
||||||
const text = document.getElementById('inputText').value.trim();
|
|
||||||
if (!text) return setStatus('Введите текст для эталона!');
|
|
||||||
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
|
||||||
await playStereo(text, userAudioBlob);
|
|
||||||
// после стерео — сравнение
|
|
||||||
const original = document.getElementById('inputText').value.trim();
|
|
||||||
if (original && userAudioBlob) {
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||||
|
|
||||||
|
// ---------- Индикатор микрофона ----------
|
||||||
|
function startMicMeter(stream) {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
micAnalyser = ctx.createAnalyser();
|
||||||
|
micAnalyser.fftSize = 256;
|
||||||
|
src.connect(micAnalyser);
|
||||||
|
const bar = document.getElementById('micBar');
|
||||||
|
const fill = document.getElementById('micFill');
|
||||||
|
bar.style.display = 'block';
|
||||||
|
fill.style.width = '0%';
|
||||||
|
const data = new Uint8Array(micAnalyser.frequencyBinCount);
|
||||||
|
function tick() {
|
||||||
|
if (!micAnalyser) return;
|
||||||
|
micAnalyser.getByteFrequencyData(data);
|
||||||
|
const max = Math.max(...data);
|
||||||
|
const pct = Math.min(100, Math.round(max / 255 * 100));
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50';
|
||||||
|
micAnimId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
} catch(e) { console.warn('Mic meter:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMicMeter() {
|
||||||
|
if (micAnimId) cancelAnimationFrame(micAnimId);
|
||||||
|
micAnimId = null;
|
||||||
|
micAnalyser = null;
|
||||||
|
document.getElementById('micBar').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- TTS ----------
|
||||||
|
function playTTS(text) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const utter = new SpeechSynthesisUtterance(text);
|
||||||
|
utter.lang = 'it-IT';
|
||||||
|
utter.rate = 0.9;
|
||||||
|
const voices = speechSynthesis.getVoices();
|
||||||
|
const it = voices.find(v => v.lang.startsWith('it'));
|
||||||
|
if (it) utter.voice = it;
|
||||||
|
utter.onend = resolve;
|
||||||
|
utter.onerror = resolve;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
speechSynthesis.speak(utter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Запись ----------
|
||||||
|
async function startRecording() {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
startMicMeter(stream);
|
||||||
|
audioChunks = [];
|
||||||
|
let mimeType = 'audio/webm;codecs=opus';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
|
mimeType = 'audio/webm';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = '';
|
||||||
|
}
|
||||||
|
const opts = mimeType ? { mimeType } : {};
|
||||||
|
mediaRecorder = new MediaRecorder(stream, opts);
|
||||||
|
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
stopMicMeter();
|
||||||
|
recordDuration = (Date.now() - recordStartTime) / 1000;
|
||||||
|
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
enable('btnCompare', true);
|
||||||
|
enable('btnStereo', true);
|
||||||
|
enable('btnPlayUser', true);
|
||||||
|
const btn = document.getElementById('btnRecord');
|
||||||
|
btn.textContent = '🎙 Записать';
|
||||||
|
btn.disabled = false;
|
||||||
|
isRecording = false;
|
||||||
|
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
|
||||||
|
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
|
||||||
|
else {
|
||||||
|
const t = document.getElementById('inputText').value.trim();
|
||||||
|
if (t) saveRecording(userAudioBlob, t, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
recordStartTime = Date.now();
|
||||||
|
setStatus('Идёт запись...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Воспроизведение (с обрезкой 0.2с в начале) ----------
|
||||||
|
async function playTrimmed(blob) {
|
||||||
|
const ctx = await getAudioCtx();
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const rawBuf = await ctx.decodeAudioData(ab);
|
||||||
|
const sr = rawBuf.sampleRate;
|
||||||
|
const data = rawBuf.getChannelData(0);
|
||||||
|
const cutSamples = Math.floor(sr * 0.2);
|
||||||
|
const trimmed = data.slice(cutSamples);
|
||||||
|
const buf = ctx.createBuffer(1, trimmed.length, sr);
|
||||||
|
buf.getChannelData(0).set(trimmed);
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = buf;
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01);
|
||||||
|
src.connect(gain).connect(ctx.destination);
|
||||||
|
src.start();
|
||||||
|
return new Promise(resolve => { src.onended = resolve; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playRec(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) await playTrimmed(rec.blob);
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playStereo(ttsText, userBlob) {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const userURL = URL.createObjectURL(userBlob);
|
||||||
|
const userAudio = new Audio(userURL);
|
||||||
|
const userSource = ctx.createMediaElementSource(userAudio);
|
||||||
|
const userPan = ctx.createStereoPanner();
|
||||||
|
userPan.pan.value = 1;
|
||||||
|
userSource.connect(userPan).connect(ctx.destination);
|
||||||
|
await playTTS(ttsText);
|
||||||
|
userAudio.play();
|
||||||
|
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Фонетический анализ ----------
|
||||||
|
function phoneticAnalysis(blob) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
|
||||||
|
const buf = await ctx.decodeAudioData(e.target.result);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
const sr = buf.sampleRate;
|
||||||
|
const len = data.length;
|
||||||
|
const frameSize = Math.floor(sr * 0.025);
|
||||||
|
const step = Math.floor(frameSize / 2);
|
||||||
|
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let meanRms = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
|
||||||
|
meanRms /= numFrames;
|
||||||
|
const energyThresh = Math.max(0.005, meanRms * 0.4);
|
||||||
|
|
||||||
|
const pitches = [];
|
||||||
|
let voicedCount = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
|
||||||
|
pitchHz = Math.round(median);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vzArr = [];
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
||||||
|
}
|
||||||
|
let articulationScore = 40;
|
||||||
|
if (vzArr.length > 0) {
|
||||||
|
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
||||||
|
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)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const clarityScore = Math.round(
|
||||||
|
voiceRatio < 0.05 ? 0 :
|
||||||
|
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSpeech = voiceRatio > 0.08;
|
||||||
|
const phonScore = hasSpeech ? Math.round(
|
||||||
|
pitchStability * 0.30 +
|
||||||
|
articulationScore * 0.25 +
|
||||||
|
clarityScore * 0.25 +
|
||||||
|
endingScore * 0.20
|
||||||
|
) : 0;
|
||||||
|
|
||||||
|
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
||||||
|
} catch (err) {
|
||||||
|
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pitchFrame(data, start, len, sr) {
|
||||||
|
const n = len;
|
||||||
|
let e0 = 0;
|
||||||
|
for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i];
|
||||||
|
if (e0 < 1e-8) return 0;
|
||||||
|
const minLag = Math.floor(sr / 400);
|
||||||
|
const maxLag = Math.min(n - 1, Math.floor(sr / 70));
|
||||||
|
let bestR = 0, bestLag = 0;
|
||||||
|
for (let lag = minLag; lag <= maxLag; lag++) {
|
||||||
|
let num = 0, e1 = 0, e2 = 0;
|
||||||
|
const m = n - lag;
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const a = data[start + i], b = data[start + i + lag];
|
||||||
|
num += a * b;
|
||||||
|
e1 += a * a;
|
||||||
|
e2 += b * b;
|
||||||
|
}
|
||||||
|
const den = Math.sqrt(e1 * e2);
|
||||||
|
const r = den > 0 ? num / den : 0;
|
||||||
|
if (r > bestR) { bestR = r; bestLag = lag; }
|
||||||
|
}
|
||||||
|
return bestR > 0.30 ? sr / bestLag : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Обрезка тишины — возвращает {buffer, startSample, endSample} ----------
|
||||||
|
async function trimSilence(blob) {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const buf = await ctx.decodeAudioData(ab);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
const sr = buf.sampleRate;
|
||||||
|
const len = data.length;
|
||||||
|
|
||||||
|
const win = Math.floor(sr * 0.01);
|
||||||
|
const rms = [];
|
||||||
|
let maxRms = 0;
|
||||||
|
for (let i = 0; i < len; i += win) {
|
||||||
|
let sum = 0, n = 0;
|
||||||
|
for (let j = i; j < i + win && j < len; j++, n++) sum += data[j] * data[j];
|
||||||
|
const v = Math.sqrt(sum / n);
|
||||||
|
rms.push(v);
|
||||||
|
if (v > maxRms) maxRms = v;
|
||||||
|
}
|
||||||
|
if (maxRms < 0.001) return null;
|
||||||
|
|
||||||
|
const threshold = maxRms * 0.02;
|
||||||
|
|
||||||
|
let startIdx = 0;
|
||||||
|
for (let i = 0; i < rms.length; i++) {
|
||||||
|
if (rms[i] > threshold) { startIdx = Math.max(0, i - 2); break; }
|
||||||
|
}
|
||||||
|
let endIdx = rms.length - 1;
|
||||||
|
for (let i = rms.length - 1; i >= 0; i--) {
|
||||||
|
if (rms[i] > threshold) { endIdx = Math.min(rms.length - 1, i + 2); break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSample = startIdx * win;
|
||||||
|
const endSample = Math.min((endIdx + 1) * win, len);
|
||||||
|
if (endSample - startSample < sr * 0.08) return null;
|
||||||
|
|
||||||
|
const trimmedBuf = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, endSample - startSample, sr);
|
||||||
|
return { buffer: trimmedBuf, data: data.slice(startSample, endSample), sr, startSample, endSample };
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- WAV кодирование (для отправки в Whisper) ----------
|
||||||
|
function encodeWAV(samples, sampleRate) {
|
||||||
|
const buf = new ArrayBuffer(44 + samples.length * 2);
|
||||||
|
const v = new DataView(buf);
|
||||||
|
const w = (o, s) => { for (let i=0;i<s.length;i++) v.setUint8(o+i, s.charCodeAt(i)); };
|
||||||
|
w(0, 'RIFF'); v.setUint32(4, 36+samples.length*2, true); w(8, 'WAVE');
|
||||||
|
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate*2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
w(36, 'data'); v.setUint32(40, samples.length*2, true);
|
||||||
|
for (let i=0;i<samples.length;i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
|
v.setInt16(44+i*2, s<0?s*0x8000:s*0x7FFF, true);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезает первые cutSec секунд, возвращает WAV Blob (для Groq)
|
||||||
|
async function trimStartBlob(blob, cutSec = 0.2) {
|
||||||
|
try {
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
const actx = new AudioContext();
|
||||||
|
const rawBuf = await actx.decodeAudioData(ab);
|
||||||
|
const sr = rawBuf.sampleRate;
|
||||||
|
const cutSamples = Math.floor(sr * cutSec);
|
||||||
|
const newLen = rawBuf.length - cutSamples;
|
||||||
|
if (newLen < sr * 0.05) { actx.close(); return blob; }
|
||||||
|
|
||||||
|
// Render trimmed via OfflineAudioContext (надёжнее ручного slice)
|
||||||
|
const offline = new OfflineAudioContext(1, newLen, sr);
|
||||||
|
const src = offline.createBufferSource();
|
||||||
|
src.buffer = rawBuf;
|
||||||
|
src.connect(offline.destination);
|
||||||
|
src.start(0, cutSec / sr);
|
||||||
|
const rendered = await offline.startRendering();
|
||||||
|
actx.close();
|
||||||
|
|
||||||
|
const wav = encodeWAV(rendered.getChannelData(0), sr);
|
||||||
|
return new Blob([wav], { type: 'audio/wav' });
|
||||||
|
} catch(e) {
|
||||||
|
console.log('[trimStartBlob] fallback to raw:', e);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
// ---------- Сравнение текстов (Левенштейн) и doCompare ----------
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return text.toLowerCase().replace(/[.,!?;:«»"'’]/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshtein(a, b) {
|
||||||
|
const m = a.length, n = b.length;
|
||||||
|
const dp = Array.from({length: m+1}, (_,i) => [i]);
|
||||||
|
for (let j=0; j<=n; j++) dp[0][j] = j;
|
||||||
|
for (let i=1; i<=m; i++)
|
||||||
|
for (let j=1; j<=n; j++)
|
||||||
|
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
|
||||||
|
return dp[m][n];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordSimilarity(a, b) {
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
if (maxLen === 0) return 1;
|
||||||
|
return 1 - levenshtein(a, b) / maxLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTexts(original, transcribed) {
|
||||||
|
const a = normalize(original).split(/\s+/);
|
||||||
|
const b = normalize(transcribed).split(/\s+/);
|
||||||
|
let totalScore = 0;
|
||||||
|
const result = a.map(word => {
|
||||||
|
let best = 0, bestMatch = '';
|
||||||
|
for (const t of b) {
|
||||||
|
const sim = wordSimilarity(word, t);
|
||||||
|
if (sim > best) { best = sim; bestMatch = t; }
|
||||||
|
}
|
||||||
|
totalScore += best;
|
||||||
|
const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad';
|
||||||
|
return { word, quality, bestMatch, score: Math.round(best*100) };
|
||||||
|
});
|
||||||
|
const score = Math.round((totalScore / a.length) * 100);
|
||||||
|
return { score, words: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiff(words) {
|
||||||
|
return words.map(({ word, quality, bestMatch, score }) => {
|
||||||
|
const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red';
|
||||||
|
const tip = quality !== 'ok' ? ` → ${bestMatch} (${score}%)` : '';
|
||||||
|
return `<span style="color:${color}; font-weight:bold">${word}</span><span style="color:#888; font-size:0.85em">${tip}</span>`;
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
let whisperResult;
|
||||||
|
try {
|
||||||
|
whisperResult = await transcribe(blob);
|
||||||
|
} catch(e) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
isAnalyzing = false;
|
||||||
|
setStatus('Ошибка анализа.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
clearInterval(timerId);
|
||||||
|
|
||||||
|
let recHTML = '', diffHTML = '', pronounceHTML = '';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Запускаем pronunciation assessment (параллельно, не блокируем)
|
||||||
|
assessPronunciation(originalText, whisperResult).then(assess => {
|
||||||
|
if (assess) {
|
||||||
|
const el = document.getElementById('pronounce');
|
||||||
|
if (el) el.innerHTML = renderPronunciation(assess);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recHTML = '⚠️ Не распознано';
|
||||||
|
diffHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('score').innerHTML = recHTML;
|
||||||
|
document.getElementById('pronounce').innerHTML = pronounceHTML;
|
||||||
|
document.getElementById('diff').innerHTML = diffHTML;
|
||||||
|
|
||||||
|
// Слоги — в отдельный div, не в diff (diff перезаписывается)
|
||||||
|
_currentBlob = blob;
|
||||||
|
_currentSylBuf = null;
|
||||||
|
_speechOnset = 0;
|
||||||
|
_speechEnd = 0;
|
||||||
|
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
|
||||||
|
? mapSyllablesToTimestamps(originalText, whisperResult.words)
|
||||||
|
: null;
|
||||||
|
console.log('[SYL] map:', _currentSylMap);
|
||||||
|
const sylDiv = document.getElementById('syllables');
|
||||||
|
sylDiv.innerHTML = '';
|
||||||
|
// Показываем отладочную инфу: диапазон речи и первый/последний слог
|
||||||
|
if (_currentSylMap && _currentSylMap.length > 0) {
|
||||||
|
const first = _currentSylMap[0], last = _currentSylMap[_currentSylMap.length - 1];
|
||||||
|
const dbg = document.createElement('div');
|
||||||
|
dbg.style.cssText = 'font-size:0.7em;color:#aaa;margin-top:2px';
|
||||||
|
dbg.textContent = '⏱ речь: ' + (first.start||0).toFixed(2) + '–' + (last.end||0).toFixed(2) + 'с | ' +
|
||||||
|
_currentSylMap.map(s => s.syl + '[' + (s.start||0).toFixed(2) + ']').join(' ');
|
||||||
|
sylDiv.appendChild(dbg);
|
||||||
|
}
|
||||||
|
const sy = syllabifyIT(originalText);
|
||||||
|
if (sy && sy.includes('-')) {
|
||||||
|
const syls = sy.split('-');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.style.cssText = 'margin-top:8px;font-size:1.2em;letter-spacing:2px;user-select:none';
|
||||||
|
syls.forEach((s, i) => {
|
||||||
|
if (i > 0) {
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.style.cssText = 'color:#ccc;margin:0 1px';
|
||||||
|
sep.textContent = '-';
|
||||||
|
div.appendChild(sep);
|
||||||
|
}
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.textContent = s;
|
||||||
|
chip.style.cssText = 'cursor:pointer;padding:2px 6px;border-radius:4px;transition:background 0.1s';
|
||||||
|
chip.onmouseover = () => { chip.style.background = '#d0d0ff'; };
|
||||||
|
chip.onmouseout = () => { chip.style.background = ''; };
|
||||||
|
chip.onclick = () => { chip.style.background = '#a0a0ff'; setTimeout(() => { chip.style.background = '#d0d0ff'; }, 200); playSyllable(i, syls.length); };
|
||||||
|
div.appendChild(chip);
|
||||||
|
});
|
||||||
|
sylDiv.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Готово.');
|
||||||
|
isAnalyzing = false;
|
||||||
|
return whisperResult.text || '';
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
|
const GROQ_API_KEY = '';
|
||||||
|
const VERSION = 'v107';
|
||||||
|
|
||||||
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
|
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||||
|
let recordStartTime = 0, recordDuration = 0;
|
||||||
|
|
||||||
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
|
function enable(id, val) { document.getElementById(id).disabled = !val; }
|
||||||
|
|
||||||
|
// ---------- Инициализация ----------
|
||||||
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
renderHistory();
|
||||||
|
renderRecHistory();
|
||||||
|
|
||||||
|
// ---------- Кнопка Запись ----------
|
||||||
|
document.getElementById('btnRecord').onclick = async function () {
|
||||||
|
const btn = this;
|
||||||
|
if (isRecording) {
|
||||||
|
btn.disabled = true;
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isRecording = true;
|
||||||
|
btn.textContent = '■ Стоп';
|
||||||
|
await startRecording();
|
||||||
|
} catch (err) {
|
||||||
|
let msg = '⛔ ';
|
||||||
|
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Нажмите на замочек 🔒 в адресной строке → Микрофон → Разрешить.';
|
||||||
|
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден. Подключите микрофон.';
|
||||||
|
else if (err.name === 'NotReadableError') msg += 'Микрофон занят другим приложением.';
|
||||||
|
else msg += 'Ошибка: ' + (err.message || err.name);
|
||||||
|
setStatus(msg);
|
||||||
|
btn.textContent = '🎙 Записать';
|
||||||
|
isRecording = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Воспроизведение своей записи ----------
|
||||||
|
document.getElementById('btnPlayUser').onclick = async () => {
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи!');
|
||||||
|
setStatus('🔊 Ваша запись...');
|
||||||
|
try {
|
||||||
|
await playTrimmed(userAudioBlob);
|
||||||
|
setStatus('Готово.');
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('Ошибка воспроизведения.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Кнопки ----------
|
||||||
|
document.getElementById('btnTTS').onclick = async () => {
|
||||||
|
ttsText = document.getElementById('inputText').value.trim();
|
||||||
|
if (!ttsText) return setStatus('Введите текст!');
|
||||||
|
saveToHistory(ttsText);
|
||||||
|
translateToRussian(ttsText);
|
||||||
|
setStatus('🔊 Эталон...');
|
||||||
|
await playTTS(ttsText);
|
||||||
|
setStatus('Готово.');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnCompare').onclick = async () => {
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи!');
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnStereo').onclick = async () => {
|
||||||
|
const text = document.getElementById('inputText').value.trim();
|
||||||
|
if (!text) return setStatus('Введите текст для эталона!');
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
||||||
|
await playStereo(text, userAudioBlob);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- История: compareRec / stereoRec ----------
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
await playStereo(rec.word, rec.blob);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// ---------- Хранилище (IndexedDB + localStorage) ----------
|
||||||
|
|
||||||
|
function openRecDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('lyngvo_recs', 1);
|
||||||
|
req.onupgradeneeded = () => { req.result.createObjectStore('recs', { keyPath: 'id', autoIncrement: true }); };
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
store.add({ word, duration, blob, ts: Date.now() });
|
||||||
|
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
||||||
|
if (all.length > 10) {
|
||||||
|
const toDelete = all.sort((a,b) => a.ts - b.ts).slice(0, all.length - 10);
|
||||||
|
for (const rec of toDelete) store.delete(rec.id);
|
||||||
|
}
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readonly');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
||||||
|
return all.sort((a,b) => b.ts - a.ts);
|
||||||
|
} catch(e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRecHistory() {
|
||||||
|
const recs = await loadRecordings();
|
||||||
|
const div = document.getElementById('recHistory');
|
||||||
|
if (!recs.length) { div.innerHTML = ''; return; }
|
||||||
|
div.innerHTML = '<div style="color:#999;margin-bottom:4px">📼 История записей (выберите для стерео):</div>' +
|
||||||
|
recs.map(r => {
|
||||||
|
const d = new Date(r.ts);
|
||||||
|
const time = d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||||||
|
let trHTML = '';
|
||||||
|
if (r.transcription) {
|
||||||
|
trHTML = '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' +
|
||||||
|
r.transcription.split(/\s+/).map(w => {
|
||||||
|
const sy = syllabifyIT(w);
|
||||||
|
if (sy.includes('-')) {
|
||||||
|
return sy.split('-').map((s,si) =>
|
||||||
|
'<span style="cursor:pointer;padding:0 2px;border-radius:3px"' +
|
||||||
|
' onmouseover="this.style.background=\'#ffeb3b\'"' +
|
||||||
|
' onmouseout="this.style.background=\'\'"' +
|
||||||
|
' onclick="playHistorySyl(' + r.id + ',' + si + ',' + sy.split('-').length + ')">' + s + '</span>'
|
||||||
|
).join('<span style="color:#ccc">-</span>');
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}).join(' ') +
|
||||||
|
'</span>';
|
||||||
|
}
|
||||||
|
return '<div class="recItem">' +
|
||||||
|
'<span style="cursor:pointer" onclick="document.getElementById(\'inputText\').value=\'' +
|
||||||
|
r.word.replace(/'/g, "\\'") + '\';ttsText=\'' + r.word.replace(/'/g, "\\'") + '\'">' + r.word + '</span>' +
|
||||||
|
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
|
||||||
|
'<button onclick="playRec(' + r.id + ')">▶</button>' +
|
||||||
|
'<button onclick="stereoRec(' + r.id + ')">🎧</button>' +
|
||||||
|
'<button onclick="compareRec(' + r.id + ')">📊</button>' +
|
||||||
|
trHTML +
|
||||||
|
'<button onclick="deleteRec(' + r.id + ')" style="background:#e44;padding:4px 8px">🗑</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).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) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- История фраз (localStorage) ----------
|
||||||
|
const HIST_KEY = 'lyngvo_phrases';
|
||||||
|
function loadHistory() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
function saveToHistory(text) {
|
||||||
|
let hist = loadHistory().filter(t => t !== text);
|
||||||
|
hist.unshift(text);
|
||||||
|
hist = hist.slice(0, 5);
|
||||||
|
localStorage.setItem(HIST_KEY, JSON.stringify(hist));
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
function renderHistory() {
|
||||||
|
const hist = loadHistory();
|
||||||
|
const div = document.getElementById('history');
|
||||||
|
if (!hist.length) { div.innerHTML = ''; return; }
|
||||||
|
div.innerHTML = hist.map(t =>
|
||||||
|
'<span onclick="var v=\'' + t.replace(/'/g, "\\'") +
|
||||||
|
'\';document.getElementById(\'inputText\').value=v;ttsText=v;renderHistory()">' + t + '</span>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
// ---------- Слоги ----------
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
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 для проигрывания слогов ----------
|
||||||
|
let _audioCtx = null;
|
||||||
|
async function getAudioCtx() {
|
||||||
|
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (_audioCtx.state === 'suspended') await _audioCtx.resume();
|
||||||
|
return _audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _currentBlob = null;
|
||||||
|
let _currentSylBuf = null;
|
||||||
|
let _currentSylMap = null; // результат mapSyllablesToTimestamps
|
||||||
|
let _speechOnset = 0; // реальное начало речи по амплитуде (сек)
|
||||||
|
let _speechEnd = 0; // реальный конец речи по амплитуде (сек)
|
||||||
|
|
||||||
|
// Поиск реального начала/конца речи по амплитуде аудиобуфера
|
||||||
|
function findSpeechRange(buf) {
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
const sr = buf.sampleRate;
|
||||||
|
// Порог: 3% от максимальной амплитуды
|
||||||
|
let maxAmp = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) maxAmp = Math.max(maxAmp, Math.abs(data[i]));
|
||||||
|
const threshold = maxAmp * 0.03;
|
||||||
|
|
||||||
|
let onset = 0, ending = buf.duration;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
if (Math.abs(data[i]) > threshold) { onset = i / sr; break; }
|
||||||
|
}
|
||||||
|
for (let i = data.length - 1; i >= 0; i--) {
|
||||||
|
if (Math.abs(data[i]) > threshold) { ending = i / sr; break; }
|
||||||
|
}
|
||||||
|
// Минимальная длительность речи
|
||||||
|
if (ending - onset < 0.1) { onset = 0; ending = buf.duration; }
|
||||||
|
return { onset, ending };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSyllable(sylIdx, total) {
|
||||||
|
if (!_currentBlob) return;
|
||||||
|
try {
|
||||||
|
const ctx = await getAudioCtx();
|
||||||
|
if (!_currentSylBuf) {
|
||||||
|
const ab = await _currentBlob.arrayBuffer();
|
||||||
|
_currentSylBuf = await ctx.decodeAudioData(ab);
|
||||||
|
const range = findSpeechRange(_currentSylBuf);
|
||||||
|
_speechOnset = range.onset;
|
||||||
|
_speechEnd = range.ending;
|
||||||
|
console.log('[SYL] speechRange: ' + _speechOnset.toFixed(3) + '–' + _speechEnd.toFixed(3) + 's (total ' + _currentSylBuf.duration.toFixed(3) + 's)');
|
||||||
|
}
|
||||||
|
|
||||||
|
let start, len;
|
||||||
|
|
||||||
|
// Используем точные таймстемпы Whisper + коррекция по амплитуде
|
||||||
|
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();
|
||||||
|
src.buffer = _currentSylBuf;
|
||||||
|
src.connect(ctx.destination);
|
||||||
|
src.start(0, start, len);
|
||||||
|
} catch(e) { console.log('[SYL] ERR', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проигрывание слога из истории (загрузка blob из IndexedDB)
|
||||||
|
async function playHistorySyl(recId, sylIdx, total) {
|
||||||
|
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(recId); req.onsuccess = () => r(req.result); });
|
||||||
|
if (!rec?.blob) return;
|
||||||
|
_currentBlob = rec.blob;
|
||||||
|
_currentSylBuf = null;
|
||||||
|
_currentSylMap = null;
|
||||||
|
await playSyllable(sylIdx, total);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// ---------- 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) {
|
||||||
|
if (!GROQ_API_KEY) return { text: '', words: [], error: 'no_key' };
|
||||||
|
const tStart = performance.now();
|
||||||
|
try {
|
||||||
|
const wavBlob = await blobToWav(blob);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', wavBlob, 'audio.wav');
|
||||||
|
fd.append('model', 'whisper-1');
|
||||||
|
fd.append('language', 'it');
|
||||||
|
fd.append('response_format', 'verbose_json');
|
||||||
|
fd.append('timestamp_granularities[]', 'word');
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 30000);
|
||||||
|
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
||||||
|
body: fd,
|
||||||
|
signal: ctrl.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
const raw = await r.text();
|
||||||
|
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
|
||||||
|
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) {
|
||||||
|
console.log('[TRANSCRIBE] ERR', e);
|
||||||
|
return { text: '', words: [], error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateToRussian(text) {
|
||||||
|
if (!GROQ_API_KEY) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://lang.kube5s.ru/openai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
||||||
|
max_tokens: 50,
|
||||||
|
temperature: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.choices?.[0]?.message?.content) {
|
||||||
|
document.getElementById('translation').textContent = '🇷🇺 ' + data.choices[0].message.content;
|
||||||
|
}
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: lyngvo
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: lyngvo
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: lyngvo
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
||||||
volumeMounts:
|
|
||||||
- name: html
|
|
||||||
mountPath: /usr/share/nginx/html
|
|
||||||
- name: nginx-conf
|
|
||||||
mountPath: /etc/nginx/conf.d/default.conf
|
|
||||||
subPath: default.conf
|
|
||||||
volumes:
|
|
||||||
- name: html
|
|
||||||
configMap:
|
|
||||||
name: lyngvo-html
|
|
||||||
- name: nginx-conf
|
|
||||||
configMap:
|
|
||||||
name: lyngvo-nginx
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: lyngvo
|
|
||||||
ports:
|
|
||||||
- port: 80
|
|
||||||
targetPort: 80
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: lyngvo
|
|
||||||
namespace: default
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
nginx.ingress.kubernetes.io/hsts: "true"
|
|
||||||
nginx.ingress.kubernetes.io/hsts-max-age: "31536000"
|
|
||||||
nginx.ingress.kubernetes.io/hsts-include-subdomains: "true"
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- capire.kube5s.ru
|
|
||||||
secretName: capire-tls
|
|
||||||
rules:
|
|
||||||
- host: capire.kube5s.ru
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: lyngvo
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name lang.kube5s.ru;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Агрессивный запрет кеширования — Chrome HSTS/cache bypass
|
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
|
||||||
add_header Pragma "no-cache" always;
|
|
||||||
add_header Expires "0" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Lyngvo — тренажёр произношения
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name lang.kube5s.ru;
|
||||||
|
location /.well-known/acme-challenge/ { root /var/www/certbot; }
|
||||||
|
location / { return 301 https://$host$request_uri; }
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
#http2 on;
|
||||||
|
server_name lang.kube5s.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/lang.kube5s.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/lang.kube5s.ru/privkey.pem;
|
||||||
|
|
||||||
|
client_body_timeout 120s;
|
||||||
|
client_body_buffer_size 128k;
|
||||||
|
|
||||||
|
root /var/www/lyngvo;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Groq API proxy (multipart POST)
|
||||||
|
location /openai/ {
|
||||||
|
proxy_pass http://127.0.0.1:8765/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Groq Whisper WebSocket (DPI-proof — base64 чанки)
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:8766;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Будущий бэкенд (Python/Go)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from flask import Flask, request, Response
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
GROQ_BASE = "https://api.proxyapi.ru/openai"
|
||||||
|
|
||||||
|
CORS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Импорт pronounce после создания app
|
||||||
|
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"])
|
||||||
|
def options(path):
|
||||||
|
return Response(status=204, headers=CORS)
|
||||||
|
|
||||||
|
@app.route("/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
def proxy(subpath):
|
||||||
|
url = f"{GROQ_BASE}/{subpath}"
|
||||||
|
hdrs = {}
|
||||||
|
if request.headers.get("Authorization"):
|
||||||
|
hdrs["Authorization"] = request.headers["Authorization"]
|
||||||
|
if request.headers.get("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,
|
||||||
|
data=body, timeout=60, stream=True)
|
||||||
|
out = dict(CORS)
|
||||||
|
out["Content-Type"] = r.headers.get("Content-Type", "application/json")
|
||||||
|
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__":
|
||||||
|
app.run(host="127.0.0.1", port=8765)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL
|
||||||
Reference in New Issue
Block a user