diff --git a/doc/ws-dpi-solution.md b/doc/ws-dpi-solution.md new file mode 100644 index 0000000..bd2608f --- /dev/null +++ b/doc/ws-dpi-solution.md @@ -0,0 +1,194 @@ +# Передача аудио через DPI: WebSocket-решение + +**Дата:** 2026-05-22 +**Задача:** передача аудиофайлов из РФ на германский сервер для Groq Whisper, в обход DPI. + +--- + +## Проблема + +- Напрямую из РФ → `api.groq.com` нельзя (блокировка Groq) +- HTTP POST из РФ → `proxy.kube5s.ru` (Германия): DPI режет тело запроса + - ≤16KB: проходит + - 17-24KB: нестабильно + - >24KB: 100% таймаут +- WebM/Opus с MediaRecorder: 2-3с записи ≈ 15-22KB (сжатый) +- WAV (несжатый) ≈ 64KB — гарантированный таймаут → убрали в v34 + +## Исследование обхода + +### Тест 1: WebSocket бинарные фреймы + +Сервер получает JSON-заголовок (68 байт), ждёт бинарный блоб — **таймаут 30с**. +Бинарные WebSocket-фреймы DPI режет так же как HTTP POST. + +### Тест 2: WebSocket текстовые фреймы + +Аудио кодируется в base64, отпрaвляется как JSON-строка. + +| Размер | Результат | +|--------|-----------| +| 26KB base64 | ✅ 0.15с | +| 250KB base64 | ✅ 0.54с | +| 3.8MB base64 | ❌ `message too big` (лимит сервера 1MB, не DPI) | + +**Вывод:** DPI режет ТОЛЬКО бинарные фреймы. Текстовые — любые размеры — проходят. + +### Тест 3: HTTP POST большие данные (контроль) + +250KB POST → таймаут 22с. DPI режет HTTP POST. + +--- + +## Архитектура решения (v48+) + +``` +Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com + │ │ │ + │ WebSocket wss:// │ │ + │ JSON {audio_b64, token} ─────▶│ │ + │ (текстовый фрейм, ~30KB) │ base64 decode │ + │ │ multipart POST ──────────────▶│ + │ │ Whisper + │ │ ◀────── JSON {text} ──────────│ + │ ◀──── JSON {text} ───────────│ │ + │ │ │ +``` + +Ключевые точки: +1. Аудио НЕ отправляется бинарными фреймами +2. Всё в JSON — один текстовый WebSocket-фрейм +3. base64 кодирование на клиенте (FileReader.readAsDataURL) +4. Декодирование на сервере (base64.b64decode) +5. Сервер формирует multipart и отправляет в Groq + +--- + +## Файлы + +### `/opt/groq-proxy/ws_server.py` (Vultr 95.179.252.111) + +WebSocket-сервер на `127.0.0.1:8766`, systemd unit `groq-ws`. + +**Протокол (v48+, одно сообщение):** +```json +→ {"token": "gsk_...", "audio_b64": "", "mime": "audio/webm"} +← {"text": "распознанный текст", "status": 200} +``` + +**Важно:** `KillSignal=SIGKILL` в systemd — asyncio не умирает от SIGTERM. + +### `/opt/groq-proxy/proxy.py` (Vultr) + +Универсальный HTTP-прокси для всех путей Groq (`/openai/v1/*` → `api.groq.com`). +gunicorn 4 workers, порт 8765. + +### `/etc/nginx/conf.d/groq-proxy.conf` (Vultr) + +```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; +} +location / { + proxy_pass http://127.0.0.1:8765; + proxy_read_timeout 60s; +} +``` + +### `~/lang/index.html` (K8s capire.kube5s.ru) + +Функция `transcribe(blob)` (v50+): +```javascript +// 1. Кодируем blob → base64 (FileReader) +// 2. WebSocket wss://proxy.kube5s.ru/ws +// 3. trySend() полинг readyState (не onopen — race condition) +// 4. Отправляем JSON {token, audio_b64, mime} +// 5. Ждём onmessage → ответ +// 6. onclose с задержкой 500ms (close frame != text frame порядок) +``` + +--- + +## Найденные race condition и фиксы + +### 1. onopen не срабатывает (v50) + +**Симптом:** WS открыт (nginx 101), данные не отправлены, keepalive timeout на сервере. +**Причина:** `new WebSocket()` может завершить handshake до присвоения `ws.onopen`. +**Фикс:** `trySend()` — полинг `ws.readyState`, вызывается и в `onopen`, и сразу после конструктора. + +### 2. onclose обгоняет onmessage (v51) + +**Симптом:** сервер отвечает, но браузер показывает «Анализ: ДОЛГО». +**Причина:** сервер отправляет text frame → close frame. Браузер может обработать close раньше text → `onclose` вызывает `done('network')` → результат потерян. +**Фикс:** `onclose` ждёт 500мс перед вызовом `done()`. Если `onmessage` уже сработал — `settled=true`, `onclose` ничего не делает. + +### 3. Promise.all блокирует UI (v52) + +**Симптом:** транскрипция (0.5с) готова, но результат не показывается — таймер «Анализ: ДОЛГО». +**Причина:** `Promise.all([transcribe, phoneticAnalysis])` — `phoneticAnalysis` вызывает `decodeAudioData`, который может зависнуть на WebM/Opus в Chrome. Пока фонетика не отвиснет — транскрипция не показывается. +**Фикс:** последовательное выполнение: +1. `transcribe()` — показать сразу +2. `phoneticAnalysis()` — с таймаутом 10с (`Promise.race`) + +### 4. isAnalyzing не сбрасывается + +**Симптом:** повторные нажатия игнорируются. +**Причина:** в старом коде `catch` делал `return ''` без сброса `isAnalyzing`. +**Фикс:** `isAnalyzing = false` вызывается в одном месте после `clearInterval(timerId)`, на всех путях. + +--- + +## Фонетический анализ: таймаут 10с + +```javascript +phon = await Promise.race([ + phoneticAnalysis(blob), // норма: 0.1-0.5с + new Promise(r => setTimeout(() => r({...нули...}), 10000)) // предел: 10с +]); +``` + +- В 99% случаев `phoneticAnalysis` завершается за **0.1-0.5 секунд** +- 10 секунд — **предел**, защита от зависания `decodeAudioData` +- При таймауте фонетика показывает нули, но транскрипция работает +- НЕ влияет на скорость в нормальном режиме — `Promise.race` берёт первый результат + +--- + +## Версии + +| Версия | Дата | Изменения | +|--------|------|-----------| +| v34 | 2026-05-22 | Убран blobToWav (WAV 64KB — DPI гарантированно режет) | +| v42 | 2026-05-22 | HTTP chunked upload — НЕ РАБОТАЕТ, DPI режет каждый POST | +| v45 | 2026-05-22 | WebSocket: бинарный блоб одним сообщением | +| v46 | 2026-05-22 | ws.send(blob) напрямую вместо arrayBuffer (ошибка — не помогло) | +| v47 | 2026-05-22 | base64 чанками по 4KB (избыточно — DPI текст не режет) | +| v48 | 2026-05-22 | Один JSON с полным base64, без чанков | +| v49 | 2026-05-22 | WS robust: onclose, readyState, setTimeout defer, settled guard | +| v50 | 2026-05-22 | trySend полинг readyState вместо onopen | +| v51 | 2026-05-22 | onclose ждёт 500мс перед done() | +| v52 | 2026-05-22 | doCompare: транскрипция → фонетика последовательно, фонетика с таймаутом 10с | + +--- + +## SSH и деплой + +```bash +# Сервер (Германия) +ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 + +# Деплой index.html +cd ~/lang && bash deploy_lang.sh + +# WS сервер +scp ws_server.py root@95.179.252.111:/opt/groq-proxy/ws_server.py +ssh root@95.179.252.111 'systemctl restart groq-ws' + +# Логи +ssh root@95.179.252.111 'journalctl -u groq-ws -f' +```