# Архитектура Lyngvo — проксирование Whisper через немецкий сервер ## Общая схема ``` Браузер (Россия) Немецкий сервер (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) | Порт | Протокол | Сервис | Назначение | |------|----------|--------|------------| | 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) | ## Ошибка 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 cd ~/lang && bash deploy_lang.sh ``` ### Деплой прокси на немецкий сервер ```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 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 |