From d9ecab051dd34078cf09348db6de9f7887fd26c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 22 May 2026 21:04:07 +0400 Subject: [PATCH] =?UTF-8?q?germany:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B/kubectl-=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D0=B8=D0=BF=D1=82=D1=8B,=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20doc=20=D0=BF=D0=BE=D0=B4=20lan?= =?UTF-8?q?g.kube5s.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF_LYNGVO_v2.md | 392 ----------------------------------------- build.sh | 2 +- check_lang_status.sh | 20 --- doc/ARCHITECTURE.md | 272 +++------------------------- doc/ws-dpi-solution.md | 8 +- test_chunks.py | 34 ---- test_hello.py | 21 --- test_http.py | 26 --- test_multipart.py | 20 --- test_single.py | 29 --- test_threshold.py | 31 ---- 11 files changed, 30 insertions(+), 825 deletions(-) delete mode 100644 HANDOFF_LYNGVO_v2.md delete mode 100755 check_lang_status.sh delete mode 100644 test_chunks.py delete mode 100644 test_hello.py delete mode 100644 test_http.py delete mode 100644 test_multipart.py delete mode 100644 test_single.py delete mode 100644 test_threshold.py diff --git a/HANDOFF_LYNGVO_v2.md b/HANDOFF_LYNGVO_v2.md deleted file mode 100644 index 5212ee0..0000000 --- a/HANDOFF_LYNGVO_v2.md +++ /dev/null @@ -1,392 +0,0 @@ -# Lyngvo — инструкция для реализации MVP - ---- - -## ⛔⛔⛔ ПРАВИЛО ДЛЯ АГЕНТА — ЧИТАТЬ ПЕРВЫМ И ВЫПОЛНЯТЬ БЕЗУСЛОВНО - -**ЗАПРЕЩЕНО:** -- Задавать вопросы пользователю -- Просить уточнений -- Писать "уточните...", "какой API ключ?", "какой домен?", "подтвердите..." -- Останавливаться в середине работы -- Писать "продолжить?" или "нужно ли мне..." -- Выдавать список шагов вместо готового кода - -**ОБЯЗАТЕЛЬНО:** -- Прочитать всю инструкцию до конца -- Принять все решения самостоятельно на основе инструкции -- Создать все файлы сразу, полностью, без пропусков -- Если что-то не указано — выбрать разумный вариант и сделать -- Остановиться только когда ВСЕ файлы готовы и работают - -**Единственный допустимый результат:** готовые файлы index.html, nginx.conf, k8s/deployment.yaml, k8s/ingress.yaml — рабочие, без заглушек, без TODO. - -**GROQ API KEY для STT (Whisper):** читать из файла `token.txt` в той же папке, вставить в код как константу. - -**Домен:** capire.kube5s.ru - -**SSH на ВМ:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213` - -**Деплой:** запустить `./deploy_lang.sh` — скрипт уже есть, он делает rsync + kubectl apply. - ---- - -## Задача - -Создать веб-приложение для тренировки итальянского произношения. -Всё в одном файле `index.html` (HTML + CSS + JS inline). Никаких фреймворков, никаких npm. - ---- - -## Файловая структура - -``` -/home/naeel/lang/ - index.html ← весь код здесь - nginx.conf ← конфиг для деплоя - k8s/ - deployment.yaml - ingress.yaml -``` - ---- - -## Пользовательский сценарий (строго в этом порядке) - -1. Пользователь вводит итальянское слово/фразу в поле -2. Нажимает **"Воспроизвести эталон"** → браузер синтезирует речь итальянским голосом -3. Нажимает **"Записать"** → идёт запись с микрофона (кнопка меняется на "Стоп") -4. Нажимает **"Стоп"** → запись останавливается -5. Нажимает **"Сравнить"** → запись уходит на Whisper API, приходит транскрипция -6. На экране: - - **Similarity score** (число от 0 до 100%) - - **Diff**: слова оригинала подсвечены зелёным (совпало) / красным (не совпало) -7. Нажимает **"Стерео-воспроизведение"** → левое ухо: TTS-эталон, правое ухо: голос пользователя - ---- - -## Шаг 1 — TTS (Web Speech API) - -```js -function playTTS(text) { - return new Promise((resolve) => { - const utter = new SpeechSynthesisUtterance(text); - utter.lang = 'it-IT'; - - // ОБЯЗАТЕЛЬНО: выбрать итальянский голос явно - const voices = speechSynthesis.getVoices(); - const italianVoice = voices.find(v => v.lang.startsWith('it')); - if (italianVoice) utter.voice = italianVoice; - - utter.onend = resolve; - speechSynthesis.speak(utter); - }); -} - -// ВАЖНО: getVoices() асинхронный при первом вызове. -// Вешать на speechSynthesis.onvoiceschanged или вызывать после события: -speechSynthesis.onvoiceschanged = () => { /* голоса загружены */ }; -``` - ---- - -## Шаг 2 — Запись (MediaRecorder) - -```js -let mediaRecorder; -let audioChunks = []; -let userAudioBlob = null; - -async function startRecording() { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - audioChunks = []; - mediaRecorder = new MediaRecorder(stream); - mediaRecorder.ondataavailable = e => audioChunks.push(e.data); - mediaRecorder.onstop = () => { - userAudioBlob = new Blob(audioChunks, { type: 'audio/webm' }); - // остановить треки микрофона - stream.getTracks().forEach(t => t.stop()); - }; - mediaRecorder.start(); -} - -function stopRecording() { - mediaRecorder.stop(); -} -``` - ---- - -## Шаг 3 — Whisper STT (Groq API) - -**Endpoint:** `https://api.groq.com/openai/v1/audio/transcriptions` -**Model:** `whisper-large-v3` -**Ключ:** вписать в константу `GROQ_API_KEY` в начале JS-кода - -```js -const GROQ_API_KEY = 'ВСТАВИТЬ_КЛЮЧ_ЗДЕСЬ'; - -async function transcribe(blob) { - const formData = new FormData(); - formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' })); - formData.append('model', 'whisper-large-v3'); - formData.append('language', 'it'); - - const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', { - method: 'POST', - headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` }, - body: formData - }); - const data = await res.json(); - return data.text || ''; -} -``` - ---- - -## Шаг 4 — Сравнение текстов - -```js -function normalize(text) { - return text.toLowerCase().replace(/[.,!?;:«»""'']/g, '').trim(); -} - -function compareTexts(original, transcribed) { - const a = normalize(original).split(/\s+/); - const b = normalize(transcribed).split(/\s+/); - - let matched = 0; - const result = a.map(word => { - const found = b.includes(word); - if (found) matched++; - return { word, ok: found }; - }); - - const score = Math.round((matched / Math.max(a.length, b.length)) * 100); - return { score, words: result }; -} - -// Рендер diff: -function renderDiff(words) { - return words.map(({ word, ok }) => - `${word}` - ).join(' '); -} -``` - ---- - -## Шаг 5 — Стерео-воспроизведение (Web Audio API) - -**Главный нюанс:** Web Speech API нельзя напрямую захватить в AudioBuffer. -Решение: воспроизводить TTS через `audio`-элемент с `captureStream()` и пускать через PannerNode. - -```js -async function playStereo(ttsText, userBlob) { - const ctx = new AudioContext(); - - // --- Правый канал: голос пользователя --- - const userURL = URL.createObjectURL(userBlob); - const userAudio = new Audio(userURL); - const userSource = ctx.createMediaElementSource(userAudio); - const userPan = ctx.createStereoPanner(); - userPan.pan.value = 1; // правое ухо - userSource.connect(userPan).connect(ctx.destination); - - // --- Левый канал: TTS через скрытый audio-элемент --- - // Используем SpeechSynthesisUtterance + MediaStreamDestination - const ttsDestination = ctx.createMediaStreamDestination(); - const ttsAudio = new Audio(); - ttsAudio.srcObject = ttsDestination.stream; - const ttsSource = ctx.createMediaElementSource(ttsAudio); - const ttsPan = ctx.createStereoPanner(); - ttsPan.pan.value = -1; // левое ухо - ttsSource.connect(ttsPan).connect(ctx.destination); - - // АЛЬТЕРНАТИВА (проще и надёжнее если выше не работает в браузере): - // Просто запустить TTS и userAudio одновременно без захвата потока, - // управляя балансом через два отдельных audio-элемента с pan через Web Audio. - // TTS-элемент: pan = -1, user-элемент: pan = +1. - - // Простой вариант (рекомендуется для MVP): - await Promise.all([ - new Promise(res => { - const utter = new SpeechSynthesisUtterance(ttsText); - utter.lang = 'it-IT'; - const voices = speechSynthesis.getVoices(); - const v = voices.find(v => v.lang.startsWith('it')); - if (v) utter.voice = v; - utter.onend = res; - speechSynthesis.speak(utter); - }), - new Promise(res => { - userAudio.onended = res; - userAudio.play(); - }) - ]); -} -``` - -**Если браузер блокирует AudioContext до жеста пользователя** — создавать `ctx` только внутри обработчика клика (не на старте страницы). - ---- - -## UI (минимальный, но понятный) - -```html -
-

🇮🇹 Lyngvo

- - - -
- - - - -
- -
-
-
-
-``` - ---- - -## nginx.conf - -```nginx -server { - listen 80; - server_name lang.kube5s.ru; - root /usr/share/nginx/html; - index index.html; - location / { - try_files $uri $uri/ /index.html; - } -} -``` - ---- - -## k8s/deployment.yaml - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: lyngvo - namespace: default -spec: - replicas: 1 - selector: - matchLabels: - app: lyngvo - template: - metadata: - labels: - app: lyngvo - spec: - containers: - - name: lyngvo - image: nginx:alpine - ports: - - containerPort: 80 - volumeMounts: - - name: html - mountPath: /usr/share/nginx/html - - name: nginx-conf - mountPath: /etc/nginx/conf.d/default.conf - subPath: default.conf - volumes: - - name: html - configMap: - name: lyngvo-html - - name: nginx-conf - configMap: - name: lyngvo-nginx ---- -apiVersion: v1 -kind: Service -metadata: - name: lyngvo - namespace: default -spec: - selector: - app: lyngvo - ports: - - port: 80 - targetPort: 80 -``` - ---- - -## k8s/ingress.yaml - -```yaml -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: lyngvo - namespace: default - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "true" - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - ingressClassName: nginx - tls: - - hosts: - - lang.kube5s.ru - secretName: lyngvo-tls - rules: - - host: lang.kube5s.ru - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: lyngvo - port: - number: 80 -``` - ---- - -## Деплой (выполнять на ВМ naeel@5.172.178.213) - -```bash -# 1. Создать ConfigMap из файлов -kubectl create configmap lyngvo-html --from-file=index.html -n default --dry-run=client -o yaml | kubectl apply -f - -kubectl create configmap lyngvo-nginx --from-file=default.conf=nginx.conf -n default --dry-run=client -o yaml | kubectl apply -f - - -# 2. Применить манифесты -kubectl apply -f k8s/deployment.yaml -kubectl apply -f k8s/ingress.yaml - -# 3. После изменения index.html — обновить ConfigMap и рестартовать pod: -kubectl create configmap lyngvo-html --from-file=index.html -n default --dry-run=client -o yaml | kubectl apply -f - -kubectl rollout restart deployment/lyngvo -n default -``` - -**SSH:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213` - ---- - -## Что НЕ делать - -- Не использовать React, Vue, webpack, npm -- Не использовать OpenAI Whisper API (дорогой) — только Groq -- Не создавать бэкенд — всё в браузере, Groq вызывается напрямую из JS -- Не добавлять ничего кроме описанного выше — это MVP - ---- - -## Порядок реализации - -1. `index.html` — полный рабочий код (TTS + запись + Whisper + diff + стерео) -2. `nginx.conf` -3. `k8s/deployment.yaml` + `k8s/ingress.yaml` -4. Инструкция по деплою выше diff --git a/build.sh b/build.sh index a284678..cbd5180 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Склеивает CSS/JS в единый index.html для деплоя (ConfigMap — один файл).""" +"""Склеивает CSS/JS в единый index.html для деплоя (scp на германскую ВМ).""" import os BASE = os.path.dirname(os.path.abspath(__file__)) diff --git a/check_lang_status.sh b/check_lang_status.sh deleted file mode 100755 index 9dcc5fe..0000000 --- a/check_lang_status.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -e - -echo "--- PODS ---" -kubectl get pods -n default | grep lyngvo || true - -echo "\n--- SERVICES ---" -kubectl get svc -n default | grep lyngvo || true - -echo "\n--- INGRESS ---" -kubectl get ingress -n default | grep lyngvo || true - -echo "\n--- POD LOGS (последние 50 строк) ---" -kubectl logs deploy/lyngvo -n default --tail=50 || true - -echo "\n--- DESCRIBE INGRESS ---" -kubectl describe ingress lyngvo -n default || true - -echo "\n--- DESCRIBE SERVICE ---" -kubectl describe svc lyngvo -n default || true diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index ef298ed..4a314ed 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,263 +1,41 @@ -# Архитектура Lyngvo — проксирование Whisper через немецкий сервер +# Архитектура Lyngvo (germany, 2026-05-22) ## Общая схема ``` -Браузер (Россия) Немецкий сервер (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 │ - └──────────────────────────┘ +Браузер (Россия) Германская ВМ (Vultr, 95.179.252.111) Groq API +┌──────────────────┐ HTTPS ┌──────────────────────────────────┐ HTTPS ┌──────────┐ +│ lang.kube5s.ru │ ────────────→│ nginx:443 │ ──────→│ api.groq │ +│ (SPA, index.html)│ │ │ │ .com │ +└──────────────────┘ │ / → /var/www/lyngvo/ │ └──────────┘ + │ /openai/* → gunicorn:8765 │ + │ └─ Flask proxy.py │ + │ /api/* → :8080 (будущий) │ + └──────────────────────────────────┘ ``` -## Компоненты на немецком сервере (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) | +| Порт | Сервис | Назначение | +|------|--------|------------| +| 443 | nginx 1.31 | Терминирует TLS, раздаёт статику, проксирует /openai/ и /api/ | +| 8765 | gunicorn (Flask proxy.py) | Пробрасывает запросы в api.groq.com | -## Ошибка 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 +# → build.sh склеивает CSS/JS → dist/index.html +# → scp на 95.179.252.111:/var/www/lyngvo/index.html ``` -### Деплой прокси на немецкий сервер -```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' -``` +- Фронтенд: HTML/CSS/JS (без фреймворков), MediaRecorder API, Web Audio API +- API: Groq Whisper (транскрипция) + Groq Llama (перевод) +- Транспорт: HTTPS POST multipart/form-data (DPI обойдён) -### Проверка статуса -```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"' -``` +## DPI -### Тест 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 | +РКН режет WebSocket. HTTPS POST multipart/form-data до 150KB проходит. +Подробнее: doc/ws-dpi-solution.md diff --git a/doc/ws-dpi-solution.md b/doc/ws-dpi-solution.md index 99e2ddc..0b3940d 100644 --- a/doc/ws-dpi-solution.md +++ b/doc/ws-dpi-solution.md @@ -5,7 +5,7 @@ --- -## Результаты тестов DPI (Россия -> Германия, proxy.kube5s.ru) +## Результаты тестов DPI (Россия -> Германия, lang.kube5s.ru) | Метод | Размер | Результат | |-------|--------|-----------| @@ -27,7 +27,7 @@ ### Архитектура ``` -Браузер (РФ) proxy.kube5s.ru (Германия) api.groq.com +Браузер (РФ) lang.kube5s.ru (Германия) api.groq.com | | | | POST /openai/v1/audio/ | | | transcriptions | | @@ -60,7 +60,7 @@ async function transcribe(blob) { form.append('language', 'it'); const r = await fetch( - 'https://proxy.kube5s.ru/openai/v1/audio/transcriptions', + 'https://lang.kube5s.ru/openai/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY }, body: form } @@ -129,7 +129,7 @@ const phonTimeout = new Promise(resolve => /opt/groq-proxy/ws_server.py — WS сервер (:8766) БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ /etc/nginx/conf.d/groq-proxy.conf -Клиент (K8s capire.kube5s.ru): +Клиент (K8s lang.kube5s.ru): ~/lang/index.html — SPA ~/lang/deploy_lang.sh — деплой diff --git a/test_chunks.py b/test_chunks.py deleted file mode 100644 index 716f804..0000000 --- a/test_chunks.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -"""Test: send chunked base64 to WS, check if server receives all chunks.""" -import asyncio, json, websockets, base64, os, sys - -# Generate fake audio — random bytes, base64 encoded -fake_audio = base64.b64encode(os.urandom(40000)).decode() # ~53KB base64 -CHUNK = 14000 -total = (len(fake_audio) + CHUNK - 1) // CHUNK - -async def test(): - uri = "wss://proxy.kube5s.ru/ws" - print(f"Connecting to {uri}...") - async with websockets.connect(uri) as ws: - print(f"Connected. Sending {total} chunks of {CHUNK} chars each (total b64: {len(fake_audio)} chars)...") - for i in range(total): - chunk = fake_audio[i*CHUNK:(i+1)*CHUNK] - msg = json.dumps({"c": i, "n": total, "t": "test", "b": chunk, "m": "audio/webm"}) - await ws.send(msg) - print(f" chunk {i+1}/{total} sent ({len(chunk)} chars)") - await asyncio.sleep(0.15) # 150ms между чанками — чтобы DPI не дрочил - - # Final - await asyncio.sleep(0.15) - await ws.send(json.dumps({"c": -1})) - print(" final sent, waiting for response...") - - try: - resp = await asyncio.wait_for(ws.recv(), timeout=30) - data = json.loads(resp) - print(f"Response: {data}") - except asyncio.TimeoutError: - print("TIMEOUT — no response") - -asyncio.run(test()) diff --git a/test_hello.py b/test_hello.py deleted file mode 100644 index a002768..0000000 --- a/test_hello.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -"""Test: bare minimum — connect and send hello.""" -import asyncio, json, websockets - -async def test(): - uri = "wss://proxy.kube5s.ru/ws" - print(f"Connecting to {uri}...") - try: - async with websockets.connect(uri) as ws: - print("Connected!") - await ws.send(json.dumps({"c": -1})) - print("Sent empty final, waiting...") - try: - resp = await asyncio.wait_for(ws.recv(), timeout=15) - print(f"Response: {resp}") - except asyncio.TimeoutError: - print("TIMEOUT") - except Exception as e: - print(f"ERROR: {e}") - -asyncio.run(test()) diff --git a/test_http.py b/test_http.py deleted file mode 100644 index 18a2dfd..0000000 --- a/test_http.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -"""Test: HTTP POST large JSON body — does it pass DPI?""" -import requests, base64, os, json, time - -# Fake audio of various sizes -for kb in [30, 50, 70, 100, 150]: - audio_b64 = base64.b64encode(os.urandom(kb * 1024)).decode() - body = json.dumps({"audio_b64": audio_b64, "mime": "audio/webm"}) - size = len(body) - - t0 = time.time() - try: - r = requests.post( - "https://proxy.kube5s.ru/", - data=body, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - dt = time.time() - t0 - print(f" {kb}KB audio → JSON {size/1024:.1f}KB: HTTP {r.status_code} in {dt:.1f}s ✅") - except requests.Timeout: - print(f" {kb}KB audio → JSON {size/1024:.1f}KB: TIMEOUT ❌") - except Exception as e: - print(f" {kb}KB audio → JSON {size/1024:.1f}KB: ERR {e}") - - time.sleep(1) diff --git a/test_multipart.py b/test_multipart.py deleted file mode 100644 index ea9436f..0000000 --- a/test_multipart.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python3 -"""Test: multipart/form-data POST binary blob — no JSON, no base64, no WS.""" -import requests, os, time - -for kb in [30, 60, 100, 150]: - blob = os.urandom(kb * 1024) - t0 = time.time() - try: - r = requests.post( - "https://proxy.kube5s.ru/", - files={"file": ("audio.webm", blob, "audio/webm")}, - timeout=30 - ) - dt = time.time() - t0 - print(f" {kb}KB blob multipart: HTTP {r.status_code} in {dt:.1f}s ✅") - except requests.Timeout: - print(f" {kb}KB blob multipart: TIMEOUT ❌") - except Exception as e: - print(f" {kb}KB blob multipart: ERR {e}") - time.sleep(1) diff --git a/test_single.py b/test_single.py deleted file mode 100644 index 38abfb2..0000000 --- a/test_single.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Test: ONE big frame — does it arrive?""" -import asyncio, json, websockets, base64, os - -async def test(size_kb): - uri = "wss://proxy.kube5s.ru/ws" - # old format: single message with all data - audio_b64 = base64.b64encode(os.urandom(size_kb * 1024)).decode() - msg = json.dumps({"token": "test", "audio_b64": audio_b64, "mime": "audio/webm"}) - print(f"Single frame {len(msg)} chars ({size_kb}KB audio → {len(msg)/1024:.1f}KB JSON)...") - try: - async with websockets.connect(uri) as ws: - await ws.send(msg) - print(" sent, waiting...") - try: - resp = await asyncio.wait_for(ws.recv(), timeout=20) - data = json.loads(resp) - print(f" => {data.get('error','OK')} ({data.get('status','')})") - except asyncio.TimeoutError: - print(" => TIMEOUT") - except Exception as e: - print(f" => CONN_ERR: {e}") - -async def main(): - for kb in [10, 25, 40, 55, 70, 100]: - await test(kb) - await asyncio.sleep(1) - -asyncio.run(main()) diff --git a/test_threshold.py b/test_threshold.py deleted file mode 100644 index 970e42c..0000000 --- a/test_threshold.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Test: find DPI threshold — how many WS frames before DPI kills connection.""" -import asyncio, json, websockets, base64, os - -async def test_chunks(n_chunks, delay=0.15): - uri = "wss://proxy.kube5s.ru/ws" - chunk_data = base64.b64encode(os.urandom(5000)).decode() # ~6.7KB per chunk - try: - async with websockets.connect(uri) as ws: - # Send data chunks - for i in range(n_chunks): - msg = json.dumps({"c": i, "n": n_chunks, "t": "test", "b": chunk_data, "m": "audio/webm"}) - await ws.send(msg) - await asyncio.sleep(delay) - # Final - await ws.send(json.dumps({"c": -1})) - try: - resp = await asyncio.wait_for(ws.recv(), timeout=15) - data = json.loads(resp) - return f"OK: {data.get('error','?')}" - except asyncio.TimeoutError: - return "TIMEOUT" - except Exception as e: - return f"CONN_ERR: {e}" - -async def main(): - for n in [1, 2, 3, 4, 5, 8]: - result = await test_chunks(n, delay=0.2) - print(f" chunks={n}: {result}") - -asyncio.run(main())