diff --git a/doc/ws-dpi-solution.md b/doc/ws-dpi-solution.md index bd2608f..99e2ddc 100644 --- a/doc/ws-dpi-solution.md +++ b/doc/ws-dpi-solution.md @@ -1,194 +1,167 @@ -# Передача аудио через DPI: WebSocket-решение +# Обход DPI РКН для передачи аудио на Groq Whisper -**Дата:** 2026-05-22 -**Задача:** передача аудиофайлов из РФ на германский сервер для Groq Whisper, в обход DPI. +**Дата:** 2026-05-22 +**Итог:** WebSocket — тупик. Рабочее решение — `fetch POST` с `multipart/form-data`. --- -## Проблема +## Результаты тестов DPI (Россия -> Германия, proxy.kube5s.ru) -- Напрямую из РФ → `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 +| Метод | Размер | Результат | +|-------|--------|-----------| +| 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-фрейм | любой | ❌ | -## Исследование обхода - -### Тест 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. +**Вывод:** DPI целенаправленно блокирует WebSocket-трафик. Обычный HTTPS POST проходит до ~130KB JSON или 150KB multipart. --- -## Архитектура решения (v48+) +## Рабочее решение (v62+) + +### Архитектура ``` -Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com - │ │ │ - │ WebSocket wss:// │ │ - │ JSON {audio_b64, token} ─────▶│ │ - │ (текстовый фрейм, ~30KB) │ base64 decode │ - │ │ multipart POST ──────────────▶│ - │ │ Whisper - │ │ ◀────── JSON {text} ──────────│ - │ ◀──── JSON {text} ───────────│ │ - │ │ │ +Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com + | | | + | POST /openai/v1/audio/ | | + | transcriptions | | + | Content-Type: multipart/ | | + | form-data | | + | Authorization: Bearer KEY | | + | file= | | + | model=whisper-large-v3 | | + | language=it ---------------->| | + | | прокси пробрасывает ------>| + | | Whisper + | | <------- JSON {text} -----| + | <------- JSON {text} --------| | ``` -Ключевые точки: -1. Аудио НЕ отправляется бинарными фреймами -2. Всё в JSON — один текстовый WebSocket-фрейм -3. base64 кодирование на клиенте (FileReader.readAsDataURL) -4. Декодирование на сервере (base64.b64decode) -5. Сервер формирует multipart и отправляет в Groq +### Ключевые моменты ---- +1. Никаких WebSocket — DPI их палит +2. Никакого base64 — Blob идёт как есть в multipart +3. Никакого JSON для аудио — бинарные данные в FormData +4. Сервер НЕ менялся — универсальный Flask-прокси форвардит всё в Groq -## Файлы +### Клиент (index.html v62) -### `/opt/groq-proxy/ws_server.py` (Vultr 95.179.252.111) +```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'); -WebSocket-сервер на `127.0.0.1:8766`, systemd unit `groq-ws`. - -**Протокол (v48+, одно сообщение):** -```json -→ {"token": "gsk_...", "audio_b64": "", "mime": "audio/webm"} -← {"text": "распознанный текст", "status": 200} + const r = await fetch( + 'https://proxy.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 }; +} ``` -**Важно:** `KillSignal=SIGKILL` в systemd — asyncio не умирает от SIGTERM. +### Сервер (proxy.py) — без изменений -### `/opt/groq-proxy/proxy.py` (Vultr) +Универсальный прокси: любой метод, любой путь -> api.groq.com. +Пробрасывает Authorization и Content-Type, стримит ответ. -Универсальный HTTP-прокси для всех путей Groq (`/openai/v1/*` → `api.groq.com`). -gunicorn 4 workers, порт 8765. - -### `/etc/nginx/conf.d/groq-proxy.conf` (Vultr) +### Nginx (groq-proxy.conf) ```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; + proxy_send_timeout 60s; + proxy_request_buffering off; } ``` -### `~/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 порядок) -``` +## Тупиковые подходы + +### WebSocket + base64 (v45-v61) + +Идея: кодировать аудио в base64, отправлять текстовыми WS-фреймами. + +Почему провалилось: +- Одиночный фрейм до ~45KB JSON проходил, больше — нет +- Чанки по 14-30KB с задержками — DPI обрывал соединение на ~3-4 фрейме +- Задержки между фреймами (150-200мс) не помогали +- Результат хаотичный: 3 фрейма ОК -> 4 таймаут -> 5 ОК -> 8 таймаут +- DPI детектит WebSocket-паттерн и режет независимо от содержимого --- -## Найденные race condition и фиксы +## Фонетический анализ: таймаут decodeAudioData -### 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с +decodeAudioData в Chrome может навсегда зависнуть на WebM/Opus >2с (баг Chrome, не DPI). ```javascript -phon = await Promise.race([ - phoneticAnalysis(blob), // норма: 0.1-0.5с - new Promise(r => setTimeout(() => r({...нули...}), 10000)) // предел: 10с +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]) ]); ``` -- В 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' ``` +Сервер (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 capire.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 фрейм |