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())