diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 56478bd..ef298ed 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,103 +1,263 @@ -# Lyngvo — архитектура и инфраструктура +# Архитектура Lyngvo — проксирование Whisper через немецкий сервер -## Что это - -Одностраничное веб-приложение (SPA) для тренировки итальянского произношения. -URL: `https://capire.kube5s.ru` - ---- - -## Стек - -| Компонент | Технология | -|---|---| -| Frontend | Single HTML file (`index.html`), vanilla JS, Web Audio API | -| Хостинг | Kubernetes (kube5s), namespace `default`, deploy `lyngvo` | -| Конфиг | ConfigMap `lyngvo-html` (index.html) + `lyngvo-nginx` (nginx.conf) | -| Ingress | `capire.kube5s.ru` | -| AI: распознавание речи | Groq Whisper (`whisper-large-v3`) | -| AI: перевод | Groq LLaMA (`llama-3.3-70b-versatile`) | -| TTS | Groq TTS (через тот же прокси) | - ---- - -## Groq API прокси - -Groq API заблокирован в России. Все запросы идут через немецкий сервер. +## Общая схема ``` -Браузер (Россия) - → HTTPS → proxy.kube5s.ru (Германия, 95.179.252.111) - nginx (TLS termination, port 443) - → localhost:8765 (Python HTTP сервер) - → HTTPS → api.groq.com +Браузер (Россия) Немецкий сервер (Vultr) Groq API +┌─────────────────┐ HTTPS/WSS ┌──────────────────────────┐ HTTPS ┌──────────┐ +│ capire.kube5s.ru │ ───────────────→│ proxy.kube5s.ru │ ──────────→│ api.groq │ +│ (K8s, index.html)│ │ │ │ .com │ +└─────────────────┘ │ nginx:443 │ └──────────┘ + │ ├─ /ws → ws_server:8766 │ + │ └─ / → gunicorn:8765 │ + │ └─ Flask proxy.py │ + └──────────────────────────┘ ``` -### Компоненты на немецком сервере +## Компоненты на немецком сервере (95.179.252.111) -| Файл/сервис | Назначение | -|---|---| -| `/opt/groq-proxy/proxy.py` | Python HTTP сервер, универсальный форвардер | -| `groq-proxy.service` (systemd) | Автозапуск proxy.py | -| `/etc/nginx/conf.d/groq-proxy.conf` | TLS + проброс на localhost:8765 | -| Let's Encrypt cert | `proxy.kube5s.ru` | +| Порт | Протокол | Сервис | Назначение | +|------|----------|--------|------------| +| 443 TCP | HTTPS | nginx | Терминирует TLS, проксирует в gunicorn/ws_server | +| 443 UDP | Hysteria2 | VPN | НЕ ТРОГАТЬ — пользовательский VPN | +| 8443 TCP | xray | VPN | НЕ ТРОГАТЬ — пользовательский VPN | +| 8765 TCP | HTTP | gunicorn (Flask) | Прямые POST-запросы (маленькие файлы ≤14KB) | +| 8766 TCP | WS | python3 ws_server.py | WebSocket (большие файлы >14KB) | -**Почему Python, а не nginx proxy_pass:** -nginx не может надёжно проксировать большие multipart/form-data (аудио) в Groq/Cloudflare — соединение обрывается с 408/502. Python `requests` делает нормальный HTTP-запрос от имени сервера. +## Ошибка 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 ~/lang/deploy_lang.sh +cd ~/lang && bash deploy_lang.sh ``` -Что делает скрипт: -1. Читает Groq API ключ из `~/lang/token.txt` -2. Вшивает ключ в `index.html` через `sed` (в HTML ключ всегда пустая строка) -3. rsync всей папки на ВМ (`5.172.178.213`) -4. `kubectl apply` configmap + deployment + ingress -5. `kubectl rollout restart deploy/lyngvo` - -**Groq ключ** хранится только в `~/lang/token.txt` (в `.gitignore`), в репо не коммитится. - ---- - -## Файлы репо - -``` -index.html — вся логика приложения (один файл) -deploy_lang.sh — скрипт деплоя -nginx.conf — конфиг nginx внутри k8s пода -k8s/ - deployment.yaml — Deployment + Service - ingress.yaml — Ingress capire.kube5s.ru -token.txt — Groq API ключ (в .gitignore) -doc/ - ARCHITECTURE.md — этот файл -``` - ---- - -## Что живёт внутри index.html - -- `VERSION` — строка версии, отображается в заголовке -- **phoneticAnalysis(blob)** — фонетический анализ аудио через Web Audio API (pitch, стабильность, артикуляция, качество окончания) -- **transcribe(blob)** — отправка аудио в Groq Whisper, возвращает распознанный текст -- **translateToRussian(text)** — перевод через Groq LLaMA -- **doCompare(blob, text, duration)** — параллельный запуск transcribe + phoneticAnalysis, формирует результат -- **История записей** — IndexedDB (`lyngvo_recs`), кнопки ▶ / 🎧 / 📊 / 🗑 -- **История фраз** — localStorage, последние 5 фраз - ---- - -## SSH доступ - +### Деплой прокси на немецкий сервер ```bash -# ВМ (Kubernetes) -ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 +# 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' -# Немецкий сервер (Groq прокси) -ssh -i ~/.ssh/vultr_openssh -o StrictHostKeyChecking=no root@95.179.252.111 +# WebSocket server +scp -i ~/.ssh/vultr_openssh /tmp/ws_server.py root@95.179.252.111:/opt/groq-proxy/ws_server.py +ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-ws' ``` + +### Проверка статуса +```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 с локальной машины +```bash +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 |