doc: полная документация обхода DPI — WebSocket провал, multipart/form-data победа
This commit is contained in:
+123
-150
@@ -1,194 +1,167 @@
|
|||||||
# Передача аудио через DPI: WebSocket-решение
|
# Обход DPI РКН для передачи аудио на Groq Whisper
|
||||||
|
|
||||||
**Дата:** 2026-05-22
|
**Дата:** 2026-05-22
|
||||||
**Задача:** передача аудиофайлов из РФ на германский сервер для Groq Whisper, в обход DPI.
|
**Итог:** WebSocket — тупик. Рабочее решение — `fetch POST` с `multipart/form-data`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Проблема
|
## Результаты тестов DPI (Россия -> Германия, proxy.kube5s.ru)
|
||||||
|
|
||||||
- Напрямую из РФ → `api.groq.com` нельзя (блокировка Groq)
|
| Метод | Размер | Результат |
|
||||||
- HTTP POST из РФ → `proxy.kube5s.ru` (Германия): DPI режет тело запроса
|
|-------|--------|-----------|
|
||||||
- ≤16KB: проходит
|
| HTTP POST с JSON-телом | 40KB JSON | ✅ |
|
||||||
- 17-24KB: нестабильно
|
| HTTP POST с JSON-телом | 133KB JSON | ✅ |
|
||||||
- >24KB: 100% таймаут
|
| HTTP POST с JSON-телом | 200KB JSON | ⚠️ погранично |
|
||||||
- WebM/Opus с MediaRecorder: 2-3с записи ≈ 15-22KB (сжатый)
|
| HTTP POST multipart/form-data | 30-150KB blob | ✅ все |
|
||||||
- WAV (несжатый) ≈ 64KB — гарантированный таймаут → убрали в v34
|
| WebSocket text-фрейм | <=45KB JSON | ✅ |
|
||||||
|
| WebSocket text-фрейм | >45KB JSON | ❌ |
|
||||||
|
| WebSocket чанки по 14KB + задержки | любой | ❌ |
|
||||||
|
| WebSocket binary-фрейм | любой | ❌ |
|
||||||
|
|
||||||
## Исследование обхода
|
**Вывод:** DPI целенаправленно блокирует WebSocket-трафик. Обычный HTTPS POST проходит до ~130KB JSON или 150KB multipart.
|
||||||
|
|
||||||
### Тест 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+)
|
## Рабочее решение (v62+)
|
||||||
|
|
||||||
|
### Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com
|
Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com
|
||||||
│ │ │
|
| | |
|
||||||
│ WebSocket wss:// │ │
|
| POST /openai/v1/audio/ | |
|
||||||
│ JSON {audio_b64, token} ─────▶│ │
|
| transcriptions | |
|
||||||
│ (текстовый фрейм, ~30KB) │ base64 decode │
|
| Content-Type: multipart/ | |
|
||||||
│ │ multipart POST ──────────────▶│
|
| form-data | |
|
||||||
│ │ Whisper
|
| Authorization: Bearer KEY | |
|
||||||
│ │ ◀────── JSON {text} ──────────│
|
| file=<blob.webm> | |
|
||||||
│ ◀──── JSON {text} ───────────│ │
|
| 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`.
|
const r = await fetch(
|
||||||
|
'https://proxy.kube5s.ru/openai/v1/audio/transcriptions',
|
||||||
**Протокол (v48+, одно сообщение):**
|
{ method: 'POST',
|
||||||
```json
|
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
||||||
→ {"token": "gsk_...", "audio_b64": "<base64>", "mime": "audio/webm"}
|
body: form }
|
||||||
← {"text": "распознанный текст", "status": 200}
|
);
|
||||||
|
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`).
|
### Nginx (groq-proxy.conf)
|
||||||
gunicorn 4 workers, порт 8765.
|
|
||||||
|
|
||||||
### `/etc/nginx/conf.d/groq-proxy.conf` (Vultr)
|
|
||||||
|
|
||||||
```nginx
|
```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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8765;
|
proxy_pass http://127.0.0.1:8765;
|
||||||
proxy_read_timeout 60s;
|
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)
|
### WebSocket + base64 (v45-v61)
|
||||||
// 2. WebSocket wss://proxy.kube5s.ru/ws
|
|
||||||
// 3. trySend() полинг readyState (не onopen — race condition)
|
Идея: кодировать аудио в base64, отправлять текстовыми WS-фреймами.
|
||||||
// 4. Отправляем JSON {token, audio_b64, mime}
|
|
||||||
// 5. Ждём onmessage → ответ
|
Почему провалилось:
|
||||||
// 6. onclose с задержкой 500ms (close frame != text frame порядок)
|
- Одиночный фрейм до ~45KB JSON проходил, больше — нет
|
||||||
```
|
- Чанки по 14-30KB с задержками — DPI обрывал соединение на ~3-4 фрейме
|
||||||
|
- Задержки между фреймами (150-200мс) не помогали
|
||||||
|
- Результат хаотичный: 3 фрейма ОК -> 4 таймаут -> 5 ОК -> 8 таймаут
|
||||||
|
- DPI детектит WebSocket-паттерн и режет независимо от содержимого
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Найденные race condition и фиксы
|
## Фонетический анализ: таймаут decodeAudioData
|
||||||
|
|
||||||
### 1. onopen не срабатывает (v50)
|
decodeAudioData в Chrome может навсегда зависнуть на WebM/Opus >2с (баг Chrome, не DPI).
|
||||||
|
|
||||||
**Симптом:** 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
|
```javascript
|
||||||
phon = await Promise.race([
|
const phonTimeout = new Promise(resolve =>
|
||||||
phoneticAnalysis(blob), // норма: 0.1-0.5с
|
setTimeout(() => resolve({ pitchHz:0, pitchStability:0, voiceClarity:0, phonScore:0 }), 15000)
|
||||||
new Promise(r => setTimeout(() => r({...нули...}), 10000)) // предел: 10с
|
);
|
||||||
|
[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 фрейм |
|
||||||
|
|||||||
Reference in New Issue
Block a user