diff --git a/deploy_lang.sh b/deploy_lang.sh index 5fee7a3..1409b26 100755 --- a/deploy_lang.sh +++ b/deploy_lang.sh @@ -7,7 +7,7 @@ echo "=== build ===" ~/lang/.venv/bin/python3 ~/lang/build.sh echo "=== inject API key ===" -KEY=$(cat ~/lang/token.txt) +KEY=$(cat ~/lang/token_ru.txt) python3 -c " h = open('$HOME/lang/dist/index.html').read() h = h.replace(\"const GROQ_API_KEY = '';\", \"const GROQ_API_KEY = '$KEY';\") diff --git a/doc/gunicorn-timeout-bug.md b/doc/gunicorn-timeout-bug.md new file mode 100644 index 0000000..fd09a4a --- /dev/null +++ b/doc/gunicorn-timeout-bug.md @@ -0,0 +1,142 @@ +# Баг: gunicorn WORKER TIMEOUT → 500 при транскрипции + +**Дата:** 2026-05-23 +**Затронуто:** https://lang.kube5s.ru — кнопка «Сравнить» (запись → Whisper → транскрипция) +**Симптом у пользователя:** каждая попытка транскрибировать → 500 / зависание / страница перезагружается + +--- + +## Что происходило в логах + +``` +nginx access.log: +03:31:31 POST /openai/v1/transcribe 200 # чанк 0 — принят +03:31:31 POST /openai/v1/transcribe 200 # чанк 1 — принят +03:31:31 POST /openai/v1/transcribe 200 # чанк 2 — принят +03:31:36 POST /openai/v1/transcribe 500 # последний чанк — CRASH +``` + +``` +journalctl -u groq-proxy: +[CRITICAL] WORKER TIMEOUT (pid:453467) +[ERROR] Error handling request POST /v1/transcribe +Traceback: + proxy.py line 29: data = request.get_json(force=True) or {} + werkzeug/wrappers/request.py: get_data() → stream.read() + gunicorn/http/body.py: reader.read(1024) → unreader.read() + gunicorn/http/unreader.py line 65: return self.sock.recv(self.mxchunk) + gunicorn/workers/base.py: handle_abort ← SIGABRT → sys.exit(1) +``` + +--- + +## Корневая причина + +**`--timeout 120` в gunicorn** (дефолт). + +Схема работы chunked-POST: +1. Браузер пишет JSON (<10 КБ) в HTTP тело +2. nginx пробрасывает с `proxy_request_buffering off` → gunicorn начинает читать тело из сокета +3. На **последнем чанке** gunicorn НЕ просто читает тело — он внутри обработчика делает HTTP-запрос к Groq API, который занимает **4–6 секунд** (Whisper large-v3) +4. gunicorn sync-воркер по умолчанию имеет **таймаут 120 секунд**... + +Но это не 120 секунд задержки — проблема в другом: +`proxy_request_buffering off` означает, что gunicorn читает тело запроса напрямую из TCP-сокета. Пока браузер держит keep-alive соединение, `sock.recv()` может блокироваться. Gunicorn мастер-процесс тикает watchdog каждую секунду и если воркер не ответил за `timeout` секунд — убивает его через SIGABRT. + +В нашем случае: nginx отправляет тело, gunicorn получает, но момент когда `stream.read()` завис + Groq API занимает несколько секунд суммарно превышало watchdog-проверку при пиковой нагрузке (или при медленном соединении из России). + +--- + +## Что предложил ИИ-ассистент перед решением (ошибочная диагностика) + +**Ошибочный диагноз** (DeepSeek / предыдущая сессия): + +> "Проблема в том что `_sessions = {}` — in-memory dict. Когда gunicorn воркер падает из-за `proxy_request_buffering off` + client disconnect (SIGABRT), сессии теряются. Нужно перевести хранение чанков на файловую систему `/tmp/lyngvo-sessions/`." + +**Почему диагноз был неверным:** + +1. Падение воркера — WORKER TIMEOUT, не SIGABRT от disconnect + Трейсбек: `unreader.py → handle_abort` — это gunicorn мастер убивает воркер по таймауту, не клиент +2. Сессии терялись именно потому что воркер убивался по таймауту — у него `_sessions` в памяти. Это следствие, а не причина +3. File-based sessions решили бы симптом (404 после restart) но не основную причину (500 на финальном чанке) +4. При `--timeout 0` watchdog отключён → воркер живёт сколько нужно → Groq успевает ответить → сессии не нужны + +**Правильный диагноз:** +gunicorn убивал воркер по таймауту 120с → in-memory сессии терялись → все последующие чанки получали 404 + +--- + +## Решение + +Одна строка в systemd unit-файле: + +```diff +- ExecStart=/usr/local/bin/gunicorn --workers 1 --bind 127.0.0.1:8765 --timeout 120 proxy:app ++ ExecStart=/usr/local/bin/gunicorn --workers 1 --bind 127.0.0.1:8765 --timeout 0 proxy:app +``` + +`--timeout 0` отключает watchdog gunicorn-мастера. Воркер живёт неограниченно долго, успевает дождаться ответа Groq. + +```bash +sed -i "s/--timeout 120/--timeout 0/" /etc/systemd/system/groq-proxy.service +systemctl daemon-reload && systemctl restart groq-proxy +``` + +--- + +## Почему `--timeout 0` безопасно в нашем случае + +- 1 воркер, 1 запрос одновременно +- Groq API всегда отвечает (максимум ~10 сек для Whisper large-v3) +- Если Groq висит вечно — Flask-запрос всё равно завершится по `requests` timeout (можно добавить явный timeout в proxy.py на уровне requests) +- Зависший воркер не блокирует других: `Restart=always` + `RestartSec=3` в systemd + +**Если добавить явный timeout для requests к Groq (дополнительная защита):** +```python +resp = requests.post(GROQ_URL, headers=headers, files=files, timeout=30) +``` + +--- + +## Хронология попыток + +| Версия | Попытка | Результат | +|--------|---------|-----------| +| v86–v88 | WebSocket base64-чанки | Chrome подключается (101), но data-фреймы не проходят (DPI блокирует WS-фреймы с данными) | +| v89 | Chunked HTTP POST JSON (<10 КБ каждый) | Чанки доходят, но последний (Groq-вызов) → 500 | +| v89 + `--timeout 0` | **Работает** | Groq успевает ответить, сессия не теряется | + +--- + +## Итоговая архитектура (рабочая) + +``` +Браузер (Россия) + │ base64 аудио → split на чанки по 3000 символов (≈2 КБ JSON каждый) + │ POST /openai/v1/transcribe {idx, total, chunk, mime, token, sid} + ▼ +nginx (lang.kube5s.ru) + │ proxy_pass http://127.0.0.1:8765/ + │ proxy_request_buffering off ← тело идёт напрямую в gunicorn + ▼ +gunicorn --timeout 0 --workers 1 + ▼ +proxy.py (_sessions in-memory dict, sid → список чанков) + │ если idx == total-1 → собрать base64 → multipart POST → Groq + ▼ +Groq Whisper large-v3 (Italian) + └─ возвращает текст → proxy → nginx → браузер +``` + +**Почему DPI пропускает JSON <10 КБ:** +Российский DPI блокирует multipart/form-data с бинарными данными (>~10 КБ) в direction Россия→Германия. JSON-текст <10 КБ — проходит. + +--- + +## Связанные файлы + +- `/opt/groq-proxy/proxy.py` — Flask-прокси, сборка чанков, вызов Groq +- `/etc/systemd/system/groq-proxy.service` — `--timeout 0` +- `/etc/nginx/conf.d/lang.kube5s.ru.conf` — `proxy_request_buffering off` +- `server/proxy.py` — локальная копия +- `js/transcribe.js` — клиентская логика чанков diff --git a/js/main.js b/js/main.js index 67bcc99..e7b0733 100644 --- a/js/main.js +++ b/js/main.js @@ -1,6 +1,6 @@ // ---------- Глобальные переменные и инициализация ---------- const GROQ_API_KEY = ''; -const VERSION = 'v89'; +const VERSION = 'v103'; let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; diff --git a/js/transcribe.js b/js/transcribe.js index 5489e61..4c9719e 100644 --- a/js/transcribe.js +++ b/js/transcribe.js @@ -1,46 +1,58 @@ -// ---------- Groq API: транскрипция (HTTP-чанки <10KB — обход DPI) ---------- +// ---------- Groq API: транскрипция (прямой POST через ProxyAPI.ru) ---------- + +async function blobToWav(blob) { + const ab = await blob.arrayBuffer(); + const ctx = new AudioContext({ sampleRate: 16000 }); + const buf = await ctx.decodeAudioData(ab); + await ctx.close(); + const mono = new Float32Array(buf.length); + for (let c = 0; c < buf.numberOfChannels; c++) { + const ch = buf.getChannelData(c); + for (let i = 0; i < buf.length; i++) mono[i] += ch[i]; + } + if (buf.numberOfChannels > 1) for (let i = 0; i < mono.length; i++) mono[i] /= buf.numberOfChannels; + const wavBuf = new ArrayBuffer(44 + mono.length * 2); + const v = new DataView(wavBuf); + const wr = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }; + wr(0, 'RIFF'); v.setUint32(4, 36 + mono.length * 2, true); + wr(8, 'WAVE'); wr(12, 'fmt '); + v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); + v.setUint32(24, 16000, true); v.setUint32(28, 32000, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); + wr(36, 'data'); v.setUint32(40, mono.length * 2, true); + let off = 44; + for (let i = 0; i < mono.length; i++) { + const s = Math.max(-1, Math.min(1, mono[i])); + v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2; + } + return new Blob([wavBuf], { type: 'audio/wav' }); +} async function transcribe(blob) { if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; const tStart = performance.now(); - - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - const CHUNK = 3000; // ~4KB base64 на чанк (POST <10KB — DPI не режет) - const total = Math.ceil(base64.length / CHUNK); - const mime = blob.type || 'audio/webm'; - console.log('[CHUNK] blob=' + (blob.size/1024).toFixed(1) + 'KB b64=' + base64.length + ' chunks=' + total); - - let sid = ''; try { - for (let i = 0; i < base64.length; i += CHUNK) { - const idx = Math.floor(i / CHUNK); - const body = JSON.stringify({ - idx, total, - chunk: base64.slice(i, i + CHUNK), - mime, token: GROQ_API_KEY, - sid - }); - const r = await fetch('https://lang.kube5s.ru/openai/v1/transcribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body - }); - const data = await r.json(); - if (idx === total - 1) { - console.log('[CHUNK] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)')); - return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null }; - } - sid = data.sid || ''; - } + const wavBlob = await blobToWav(blob); + const fd = new FormData(); + fd.append('file', wavBlob, 'audio.wav'); + fd.append('model', 'whisper-1'); + fd.append('language', 'it'); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 30000); + const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', { + method: 'POST', + headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` }, + body: fd, + signal: ctrl.signal + }); + clearTimeout(timer); + const raw = await r.text(); + console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200)); + let data; + try { data = JSON.parse(raw); } catch(pe) { return { text: '', error: 'parse:' + raw.slice(0,80) }; } + return { text: data.text || '', error: data.error?.message || data.error || null }; } catch(e) { - console.log('[CHUNK] ERR', e); - return { text: '', error: 'network' }; + console.log('[TRANSCRIBE] ERR', e); + return { text: '', error: e.message }; } } @@ -51,7 +63,7 @@ async function translateToRussian(text) { method: 'POST', headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'llama-3.3-70b-versatile', + model: 'gpt-4o', messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }], max_tokens: 50, temperature: 0 diff --git a/server/lang.kube5s.ru.conf b/server/lang.kube5s.ru.conf index 7055322..a458030 100644 --- a/server/lang.kube5s.ru.conf +++ b/server/lang.kube5s.ru.conf @@ -20,6 +20,9 @@ server { index index.html; location / { + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; + add_header Pragma "no-cache"; + expires -1; try_files $uri $uri/ /index.html; } diff --git a/server/proxy.py b/server/proxy.py index 78cd179..c1a1dcd 100644 --- a/server/proxy.py +++ b/server/proxy.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 from flask import Flask, request, Response import requests -import base64 -import uuid -import time app = Flask(__name__) -GROQ_BASE = "https://api.groq.com" +GROQ_BASE = "https://api.proxyapi.ru/openai" CORS = { "Access-Control-Allow-Origin": "*", @@ -14,72 +11,10 @@ CORS = { "Access-Control-Allow-Headers": "Authorization, Content-Type", } -# Чанковая сборка аудио (DPI bypass — каждый POST <10KB) -_sessions = {} # session_id -> {chunks, mime, total, token, expires} - @app.route("/", methods=["OPTIONS"]) def options(path): return Response(status=204, headers=CORS) -@app.route("/v1/transcribe", methods=["POST", "OPTIONS"]) -def transcribe_chunked(): - if request.method == "OPTIONS": - return Response(status=204, headers=CORS) - - data = request.get_json(force=True) or {} - idx = data.get("idx", 0) - total = data.get("total", 1) - chunk = data.get("chunk", "") - mime = data.get("mime", "audio/webm") - token = data.get("token", "") - sid = data.get("sid", "") - - # Очистка старых сессий - now = time.time() - for k in list(_sessions.keys()): - if _sessions[k]["expires"] < now: - del _sessions[k] - - if not sid: - sid = uuid.uuid4().hex[:12] - _sessions[sid] = {"chunks": {}, "mime": mime, "total": total, "token": token, "expires": now + 120} - else: - s = _sessions.get(sid) - if not s: - return Response('{"error":"session not found"}', status=404, headers=CORS, content_type="application/json") - - _sessions[sid]["chunks"][idx] = chunk - _sessions[sid]["expires"] = now + 120 - - if len(_sessions[sid]["chunks"]) >= total: - s = _sessions.pop(sid) - audio_b64 = "".join(s["chunks"][i] for i in sorted(s["chunks"])) - audio_data = base64.b64decode(audio_b64) - - ext = "webm" - if "ogg" in s["mime"]: ext = "ogg" - elif "mp4" in s["mime"] or "aac" in s["mime"]: ext = "mp4" - elif "wav" in s["mime"]: ext = "wav" - - boundary = "----ChunkedBoundary" + uuid.uuid4().hex[:16] - payload = b"".join([ - f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.{ext}"\r\nContent-Type: {s["mime"]}\r\n\r\n'.encode(), - audio_data, - f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3'.encode(), - f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nit'.encode(), - f'\r\n--{boundary}--\r\n'.encode(), - ]) - - r = requests.post(f"{GROQ_BASE}/openai/v1/audio/transcriptions", - headers={"Authorization": f"Bearer {s['token']}", "Content-Type": f"multipart/form-data; boundary={boundary}"}, - data=payload, timeout=60) - result = r.json() - out = dict(CORS) - out["Content-Type"] = "application/json" - return Response(r.text, status=r.status_code, headers=out) - else: - return Response(f'{{"ok":true,"sid":"{sid}"}}', status=200, headers=CORS, content_type="application/json") - @app.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) def proxy(subpath): url = f"{GROQ_BASE}/{subpath}" @@ -88,11 +23,15 @@ def proxy(subpath): hdrs["Authorization"] = request.headers["Authorization"] if request.headers.get("Content-Type"): hdrs["Content-Type"] = request.headers["Content-Type"] + body = request.get_data() + print(f"[PROXY] {request.method} {subpath} body={len(body)}b ct={hdrs.get('Content-Type','')[:50]}", flush=True) r = requests.request(request.method, url, headers=hdrs, - data=request.get_data(), timeout=60, stream=True) + data=body, timeout=60, stream=True) out = dict(CORS) out["Content-Type"] = r.headers.get("Content-Type", "application/json") - return Response(r.iter_content(8192), status=r.status_code, headers=out) + raw = r.content + print(f"[PROXY] ← {r.status_code} {raw[:200]}", flush=True) + return Response(raw, status=r.status_code, headers=out) if __name__ == "__main__": app.run(host="127.0.0.1", port=8765) diff --git a/token_ru.txt b/token_ru.txt new file mode 100644 index 0000000..c206752 --- /dev/null +++ b/token_ru.txt @@ -0,0 +1 @@ +sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL