52 Commits
Author SHA1 Message Date
“Naeel” c83e3d9da2 v89: чанкованный HTTP POST <10KB — обход DPI (proxy_pass fix + /v1/transcribe) 2026-05-23 07:27:43 +04:00
“Naeel” 4acc400fab v88: async onopen + 10ms задержка между чанками — буфер Chrome 2026-05-23 07:16:10 +04:00
“Naeel” a091548925 v87: nginx /ws location для lang.kube5s.ru 2026-05-23 07:09:16 +04:00
“Naeel” 23644bf214 v87: WebSocket на lang.kube5s.ru/ws — всё на одном домене 2026-05-23 07:08:56 +04:00
“Naeel” 2017a6e4be v86: WebSocket base64-чанки — обход DPI для транскрипции 2026-05-23 07:02:01 +04:00
“Naeel” 2cf853be56 v85: DPI bypass — proxy_request_buffering off + client_body_timeout 120s 2026-05-23 06:42:27 +04:00
“Naeel” 625c24b695 v84: revert прокси — DPI режет multipart на lang.kube5s.ru (408), ffmpeg убран 2026-05-23 06:38:50 +04:00
“Naeel” babf26cf6d v83: обрезка 0.2с на сервере (ffmpeg в прокси) — надёжнее браузерного WAV 2026-05-23 06:31:28 +04:00
“Naeel” 73e9efad9c v83: Whisper — сырой WebM, без WAV-конвертации 2026-05-23 06:27:35 +04:00
“Naeel” dababcf466 v82: trimStartBlob через AudioContext+OfflineRenderer — обрезка 0.2с для Whisper 2026-05-23 06:26:07 +04:00
“Naeel” 6b62ec8dd5 v81: revert WAV для Whisper — сырой WebM (обрезка только для ▶) 2026-05-23 06:20:18 +04:00
“Naeel” f7587a3dcb v80: обрезка 0.2с в начале записи — убирает щелчок (вместо trimSilence) 2026-05-23 06:13:46 +04:00
“Naeel” 689fa78734 v79: убран phoneticAnalysis — только Whisper + слоги 2026-05-23 05:57:46 +04:00
“Naeel” 80cee77afc v78: fade-in 10мс — убирает щелчок при воспроизведении 2026-05-22 21:43:00 +04:00
“Naeel” 201ca095e7 v77: фикс playTrimmed — await getAudioCtx() 2026-05-22 21:39:18 +04:00
“Naeel” 337f7f3017 v76: проигрывание через trimSilence — без щелчка в начале 2026-05-22 21:37:10 +04:00
“Naeel” 66a3e32c03 v75: убран автоанализ при проигрывании — ▶ и 🎧 только играют 2026-05-22 21:33:32 +04:00
“Naeel” c0658f6c20 v74: откат WAV — WebM для Groq, trimSilence в памяти для локального 2026-05-22 21:30:35 +04:00
“Naeel” f2762b4f0a v73: обрезка тишины (начало/конец) → WAV 2026-05-22 21:27:46 +04:00
“Naeel” 6ab7a26bbc germany: выключен HTTP/2 (DPI режет) 2026-05-22 21:23:47 +04:00
“Naeel” f056fda81a v72: alert при ошибке транскрипции для отладки 2026-05-22 21:20:24 +04:00
“Naeel” 378bedc289 germany: фикс deploy_lang.sh — ключ локально, без sed ада 2026-05-22 21:14:24 +04:00
“Naeel” cbb5b4917a germany: server/ конфиги в репе, deploy_lang.sh → rsync + build на ВМ 2026-05-22 21:09:09 +04:00
“Naeel” d9ecab051d germany: удалены тесты/kubectl-скрипты, обновлены doc под lang.kube5s.ru 2026-05-22 21:04:07 +04:00
“Naeel” 83140ded66 germany: удалён кубер-наследие, обновлены .github под германскую ВМ 2026-05-22 21:02:34 +04:00
“Naeel” 1baa3e841c v71: переезд на lang.kube5s.ru (германская ВМ, nginx, без кубера) 2026-05-22 20:44:35 +04:00
“Naeel” 67b82c726e v70: тире между слогами + onclick — playHistorySyl из IndexedDB 2026-05-22 19:05:17 +04:00
“Naeel” 1f08e3a525 v69: слоги в таблице истории — hover жёлтым 2026-05-22 19:01:50 +04:00
“Naeel” ceebfc5d58 v68: тест — жёсткие слоги Buon·gior·no с ярким hover (жёлтый) 2026-05-22 18:59:28 +04:00
“Naeel” 60749a9691 v67: слоги в отдельный #syllables div — защита от перезаписи diff 2026-05-22 18:54:38 +04:00
“Naeel” 77a75235ad v66: слоги через DOM+onmouseover/onclick — надёжнее innerHTML 2026-05-22 18:52:10 +04:00
“Naeel” abb943dd52 v65 2026-05-22 18:48:08 +04:00
“Naeel” caec0eba0b pravila: правило — всегда увеличивать VERSION после любой правки 2026-05-22 18:47:34 +04:00
“Naeel” aba175442b v64: фикс playSyllable — await resume(), отладка, :active стиль 2026-05-22 18:44:19 +04:00
“Naeel” 9c13c21898 v64: рефакторинг — разбил монолит на css/ js/ build.sh dist/ (781→43 строки index.html) 2026-05-22 18:39:13 +04:00
“Naeel” bd042be61c v63: кликабельные слоги — проигрывание сегментов аудио 2026-05-22 18:31:56 +04:00
“Naeel” 53de41a90e doc: полная документация обхода DPI — WebSocket провал, multipart/form-data победа 2026-05-22 18:23:39 +04:00
“Naeel” b71eb9a45f v62: fetch POST multipart/form-data вместо WebSocket+base64 — обход DPI 2026-05-22 18:19:19 +04:00
“Naeel” 1f3dce39a4 v61: чанк 14KB 2026-05-22 17:58:41 +04:00
“Naeel” 4945510cb2 v60: чанк 14KB 2026-05-22 17:57:00 +04:00
“Naeel” 786b937acd v60: чанки 30KB — обход DPI-порога ~48KB на WebSocket text-фреймы 2026-05-22 17:54:11 +04:00
“Naeel” 96f178b56e fix: phoneticAnalysis timeout 15s — защита от зависания decodeAudioData (Chrome bug, длинные WebM) 2026-05-22 17:40:15 +04:00
“Naeel” 3442e5d031 revert: откат до v48 — убраны v49-v53 (close/race/onopen костыли) 2026-05-22 17:32:11 +04:00
“Naeel” f4957437db v53: сервер ждёт закрытия от клиента — close frame не обгоняет text frame 2026-05-22 17:29:10 +04:00
“Naeel” 628fea51b7 doc: полный разбор WS/DPI решения, все race condition и фиксы 2026-05-22 17:25:26 +04:00
“Naeel” ff6eb0486c v52: транскрипция → фонетика последовательно, фонетика с таймаутом 10с 2026-05-22 17:23:32 +04:00
“Naeel” 0504c6009c v51: onclose даёт 500ms фору onmessage — close frame обгонял text frame 2026-05-22 17:20:25 +04:00
“Naeel” 8fc986fe7f v50: WS — trySend полинг вместо onopen, фикс race condition 2026-05-22 17:17:52 +04:00
“Naeel” f4ac9cf717 v49: WS robust — onclose, readyState, setTimeout defer, settled guard 2026-05-22 17:06:47 +04:00
“Naeel” 8f8a21bbcc v48: WS — один JSON с base64, без чанков, без бинарных фреймов 2026-05-22 17:01:30 +04:00
“Naeel” 26c5adfc79 v47: WS base64 chunks — только текстовые фреймы, DPI не режет 2026-05-22 16:52:14 +04:00
“Naeel” 86e7db4b77 v46: ws.send(blob) directly, no async/await in onopen 2026-05-22 16:38:54 +04:00
22 changed files with 2179 additions and 1667 deletions
+17 -72
View File
@@ -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
View File
@@ -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` и др.) — только после явного подтверждения с указанием конкретных объектов
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
-392
View File
@@ -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. Инструкция по деплою выше
Executable
+21
View File
@@ -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', '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)')
-20
View File
@@ -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
+17
View File
@@ -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; }
+17 -18
View File
@@ -1,25 +1,24 @@
#!/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 ==="
~/lang/.venv/bin/python3 ~/lang/build.sh
echo "=== inject API key ==="
KEY=$(cat ~/lang/token.txt) KEY=$(cat ~/lang/token.txt)
cp ~/lang/index.html /tmp/lyngvo_index.html python3 -c "
sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html 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"
# Синхронизация всей папки lang на ВМ echo "=== rsync -> VM ==="
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" ~/lang/ naeel@5.172.178.213:~/terra/lang/ rsync -az -e "$SSH" --exclude='.git' --exclude='.venv' --exclude='__pycache__' ~/lang/ ${VM}:/opt/lyngvo/
# Деплой на ВМ (index.html с ключом — через временный файл) echo "=== apply on 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 $SSH $VM 'cp /opt/lyngvo/dist/index.html /var/www/lyngvo/index.html && echo OK'
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 "=== https://lang.kube5s.ru ==="
echo "\n=== Деплой завершён. Проверяй capire.kube5s.ru ==="
+901
View File
@@ -0,0 +1,901 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Lyngvo — Итальянский тренажёр произношения</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* 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; }
</style>
</head>
<body>
<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>
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (сделайте короткую паузу перед произнесением) &nbsp;&nbsp; 4. 📊 Сравнить
</div>
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
<div id="history"></div>
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
<button id="btnTTS">▶ Эталон</button>
<button id="btnRecord">🎙 Записать</button>
<button id="btnPlayUser" disabled>🔊 Ваш голос</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="micBar" style="height:12px; background:#e0e0e0; border-radius:6px; margin:8px 0; overflow:hidden; display:none">
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
</div>
<div id="score"></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="recHistory" style="margin-top:16px;font-size:0.85em"></div>
</div>
<script>
// ---------- Хранилище (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('');
}
</script>
<script>
// ---------- Слоги ----------
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(' ');
}
// ---------- 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;
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 dur = _currentSylBuf.duration;
const start = (dur / total) * sylIdx;
const len = dur / total;
const src = ctx.createBufferSource();
src.buffer = _currentSylBuf;
src.connect(ctx.destination);
src.start(0, start, len);
} catch(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;
await playSyllable(sylIdx, total);
} catch(e) {}
}
</script>
<script>
// ---------- Groq API: транскрипция (HTTP-чанки <10KB — обход DPI) ----------
async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
const tStart = performance.now();
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const CHUNK = 3000; // ~4KB base64 на чанк (POST <10KB — DPI не режет)
const total = Math.ceil(base64.length / CHUNK);
const mime = blob.type || 'audio/webm';
console.log('[CHUNK] blob=' + (blob.size/1024).toFixed(1) + 'KB b64=' + base64.length + ' chunks=' + total);
let sid = '';
try {
for (let i = 0; i < base64.length; i += CHUNK) {
const idx = Math.floor(i / CHUNK);
const body = JSON.stringify({
idx, total,
chunk: base64.slice(i, i + CHUNK),
mime, token: GROQ_API_KEY,
sid
});
const r = await fetch('https://lang.kube5s.ru/openai/v1/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
const data = await r.json();
if (idx === total - 1) {
console.log('[CHUNK] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
}
sid = data.sid || '';
}
} catch(e) {
console.log('[CHUNK] ERR', e);
return { text: '', error: 'network' };
}
}
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: '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) { /* тихо */ }
}
</script>
<script>
// ---------- Аудио: запись, 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;
}
}
</script>
<script>
// ---------- Сравнение текстов (Левенштейн) и 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 = '';
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> &nbsp;|  🗣 <i>' + whisperResult.text + '</i>';
diffHTML =
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;50%' +
'</div>' +
renderDiff(words);
} else {
recHTML = '⚠️ Не распознано';
diffHTML = '';
}
document.getElementById('score').innerHTML = recHTML;
document.getElementById('diff').innerHTML = diffHTML;
// Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob;
_currentSylBuf = null;
const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = '';
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 || '';
}
</script>
<script>
// ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = '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';
const VERSION = 'v89';
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('Ошибка загрузки записи.'); }
}
</script>
</body>
</html>
+25 -247
View File
@@ -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 |
+167
View File
@@ -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 фрейм |
+10 -731
View File
@@ -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. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (произнесите) &nbsp;&nbsp; 4. 📊 Сравнить 1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (сделайте короткую паузу перед произнесением) &nbsp;&nbsp; 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>
@@ -44,723 +30,16 @@
</div> </div>
<div id="score"></div> <div id="score"></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/audio.js"></script>
const res = await fetch('https://proxy.kube5s.ru/openai/v1/chat/completions', { <script src="js/compare.js"></script>
method: 'POST', <script src="js/main.js"></script>
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
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> &nbsp;|  🗣 <i>' + whisperResult.text + '</i> &nbsp;|  ';
diffHTML =
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;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
View File
@@ -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;
}
}
+120
View File
@@ -0,0 +1,120 @@
// ---------- Сравнение текстов (Левенштейн) и 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 = '';
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> &nbsp;|  🗣 <i>' + whisperResult.text + '</i>';
diffHTML =
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;50%' +
'</div>' +
renderDiff(words);
} else {
recHTML = '⚠️ Не распознано';
diffHTML = '';
}
document.getElementById('score').innerHTML = recHTML;
document.getElementById('diff').innerHTML = diffHTML;
// Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob;
_currentSylBuf = null;
const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = '';
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
View File
@@ -0,0 +1,103 @@
// ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = '';
const VERSION = 'v89';
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('Ошибка загрузки записи.'); }
}
+119
View File
@@ -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('');
}
+70
View File
@@ -0,0 +1,70 @@
// ---------- Слоги ----------
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(' ');
}
// ---------- 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;
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 dur = _currentSylBuf.duration;
const start = (dur / total) * sylIdx;
const len = dur / total;
const src = ctx.createBufferSource();
src.buffer = _currentSylBuf;
src.connect(ctx.destination);
src.start(0, start, len);
} catch(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;
await playSyllable(sylIdx, total);
} catch(e) {}
}
+65
View File
@@ -0,0 +1,65 @@
// ---------- Groq API: транскрипция (HTTP-чанки <10KB — обход DPI) ----------
async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
const tStart = performance.now();
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const CHUNK = 3000; // ~4KB base64 на чанк (POST <10KB — DPI не режет)
const total = Math.ceil(base64.length / CHUNK);
const mime = blob.type || 'audio/webm';
console.log('[CHUNK] blob=' + (blob.size/1024).toFixed(1) + 'KB b64=' + base64.length + ' chunks=' + total);
let sid = '';
try {
for (let i = 0; i < base64.length; i += CHUNK) {
const idx = Math.floor(i / CHUNK);
const body = JSON.stringify({
idx, total,
chunk: base64.slice(i, i + CHUNK),
mime, token: GROQ_API_KEY,
sid
});
const r = await fetch('https://lang.kube5s.ru/openai/v1/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
const data = await r.json();
if (idx === total - 1) {
console.log('[CHUNK] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
}
sid = data.sid || '';
}
} catch(e) {
console.log('[CHUNK] ERR', e);
return { text: '', error: 'network' };
}
}
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: '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) { /* тихо */ }
}
-45
View File
@@ -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
-28
View File
@@ -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
View File
@@ -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;
}
}
+53
View File
@@ -0,0 +1,53 @@
# 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 / {
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;
}
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
from flask import Flask, request, Response
import requests
import base64
import uuid
import time
app = Flask(__name__)
GROQ_BASE = "https://api.groq.com"
CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
}
# Чанковая сборка аудио (DPI bypass — каждый POST <10KB)
_sessions = {} # session_id -> {chunks, mime, total, token, expires}
@app.route("/<path:path>", methods=["OPTIONS"])
def options(path):
return Response(status=204, headers=CORS)
@app.route("/v1/transcribe", methods=["POST", "OPTIONS"])
def transcribe_chunked():
if request.method == "OPTIONS":
return Response(status=204, headers=CORS)
data = request.get_json(force=True) or {}
idx = data.get("idx", 0)
total = data.get("total", 1)
chunk = data.get("chunk", "")
mime = data.get("mime", "audio/webm")
token = data.get("token", "")
sid = data.get("sid", "")
# Очистка старых сессий
now = time.time()
for k in list(_sessions.keys()):
if _sessions[k]["expires"] < now:
del _sessions[k]
if not sid:
sid = uuid.uuid4().hex[:12]
_sessions[sid] = {"chunks": {}, "mime": mime, "total": total, "token": token, "expires": now + 120}
else:
s = _sessions.get(sid)
if not s:
return Response('{"error":"session not found"}', status=404, headers=CORS, content_type="application/json")
_sessions[sid]["chunks"][idx] = chunk
_sessions[sid]["expires"] = now + 120
if len(_sessions[sid]["chunks"]) >= total:
s = _sessions.pop(sid)
audio_b64 = "".join(s["chunks"][i] for i in sorted(s["chunks"]))
audio_data = base64.b64decode(audio_b64)
ext = "webm"
if "ogg" in s["mime"]: ext = "ogg"
elif "mp4" in s["mime"] or "aac" in s["mime"]: ext = "mp4"
elif "wav" in s["mime"]: ext = "wav"
boundary = "----ChunkedBoundary" + uuid.uuid4().hex[:16]
payload = b"".join([
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.{ext}"\r\nContent-Type: {s["mime"]}\r\n\r\n'.encode(),
audio_data,
f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3'.encode(),
f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nit'.encode(),
f'\r\n--{boundary}--\r\n'.encode(),
])
r = requests.post(f"{GROQ_BASE}/openai/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {s['token']}", "Content-Type": f"multipart/form-data; boundary={boundary}"},
data=payload, timeout=60)
result = r.json()
out = dict(CORS)
out["Content-Type"] = "application/json"
return Response(r.text, status=r.status_code, headers=out)
else:
return Response(f'{{"ok":true,"sid":"{sid}"}}', status=200, headers=CORS, content_type="application/json")
@app.route("/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
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"]
r = requests.request(request.method, url, headers=hdrs,
data=request.get_data(), timeout=60, stream=True)
out = dict(CORS)
out["Content-Type"] = r.headers.get("Content-Type", "application/json")
return Response(r.iter_content(8192), status=r.status_code, headers=out)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8765)