germany: удалены тесты/kubectl-скрипты, обновлены doc под lang.kube5s.ru
This commit is contained in:
@@ -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 }) =>
|
|
||||||
`<span style="color:${ok ? 'green' : 'red'}; font-weight:bold">${word}</span>`
|
|
||||||
).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
|
|
||||||
<div id="app" style="max-width:600px; margin:40px auto; font-family:sans-serif">
|
|
||||||
<h1>🇮🇹 Lyngvo</h1>
|
|
||||||
|
|
||||||
<textarea id="inputText" rows="3" style="width:100%; font-size:1.2em"
|
|
||||||
placeholder="Введите итальянское слово или фразу..."></textarea>
|
|
||||||
|
|
||||||
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
|
||||||
<button id="btnTTS">▶ Эталон</button>
|
|
||||||
<button id="btnRecord">🎙 Записать</button>
|
|
||||||
<button id="btnCompare" disabled>📊 Сравнить</button>
|
|
||||||
<button id="btnStereo" disabled>🎧 Стерео</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status" style="color:gray; font-size:0.9em"></div>
|
|
||||||
<div id="score" style="font-size:1.5em; margin:12px 0"></div>
|
|
||||||
<div id="diff" style="font-size:1.2em; line-height:1.8"></div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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. Инструкция по деплою выше
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Склеивает CSS/JS в единый index.html для деплоя (ConfigMap — один файл)."""
|
"""Склеивает CSS/JS в единый index.html для деплоя (scp на германскую ВМ)."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|||||||
@@ -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
|
|
||||||
+25
-247
@@ -1,263 +1,41 @@
|
|||||||
# Архитектура Lyngvo — проксирование Whisper через немецкий сервер
|
# Архитектура Lyngvo (germany, 2026-05-22)
|
||||||
|
|
||||||
## Общая схема
|
## Общая схема
|
||||||
|
|
||||||
```
|
```
|
||||||
Браузер (Россия) Немецкий сервер (Vultr) Groq API
|
Браузер (Россия) Германская ВМ (Vultr, 95.179.252.111) Groq API
|
||||||
┌─────────────────┐ HTTPS/WSS ┌──────────────────────────┐ HTTPS ┌──────────┐
|
┌──────────────────┐ HTTPS ┌──────────────────────────────────┐ HTTPS ┌──────────┐
|
||||||
│ capire.kube5s.ru │ ───────────────→│ proxy.kube5s.ru │ ──────────→│ api.groq │
|
│ lang.kube5s.ru │ ────────────→│ nginx:443 │ ──────→│ api.groq │
|
||||||
│ (K8s, index.html)│ │ │ │ .com │
|
│ (SPA, index.html)│ │ │ │ .com │
|
||||||
└─────────────────┘ │ nginx:443 │ └──────────┘
|
└──────────────────┘ │ / → /var/www/lyngvo/ │ └──────────┘
|
||||||
│ ├─ /ws → ws_server:8766 │
|
│ /openai/* → gunicorn:8765 │
|
||||||
│ └─ / → gunicorn:8765 │
|
│ └─ Flask proxy.py │
|
||||||
│ └─ Flask proxy.py │
|
│ /api/* → :8080 (будущий) │
|
||||||
└──────────────────────────┘
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Компоненты на немецком сервере (95.179.252.111)
|
## Компоненты
|
||||||
|
|
||||||
| Порт | Протокол | Сервис | Назначение |
|
| Порт | Сервис | Назначение |
|
||||||
|------|----------|--------|------------|
|
|------|--------|------------|
|
||||||
| 443 TCP | HTTPS | nginx | Терминирует TLS, проксирует в gunicorn/ws_server |
|
| 443 | nginx 1.31 | Терминирует TLS, раздаёт статику, проксирует /openai/ и /api/ |
|
||||||
| 443 UDP | Hysteria2 | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
| 8765 | gunicorn (Flask proxy.py) | Пробрасывает запросы в api.groq.com |
|
||||||
| 8443 TCP | xray | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
|
||||||
| 8765 TCP | HTTP | gunicorn (Flask) | Прямые POST-запросы (маленькие файлы ≤14KB) |
|
|
||||||
| 8766 TCP | WS | python3 ws_server.py | WebSocket (большие файлы >14KB) |
|
|
||||||
|
|
||||||
## Ошибка 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
|
```bash
|
||||||
cd ~/lang && bash deploy_lang.sh
|
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
|
- Фронтенд: HTML/CSS/JS (без фреймворков), MediaRecorder API, Web Audio API
|
||||||
scp -i ~/.ssh/vultr_openssh /tmp/ws_server.py root@95.179.252.111:/opt/groq-proxy/ws_server.py
|
- API: Groq Whisper (транскрипция) + Groq Llama (перевод)
|
||||||
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-ws'
|
- Транспорт: HTTPS POST multipart/form-data (DPI обойдён)
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка статуса
|
## 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"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тест WebSocket с локальной машины
|
РКН режет WebSocket. HTTPS POST multipart/form-data до 150KB проходит.
|
||||||
```bash
|
Подробнее: doc/ws-dpi-solution.md
|
||||||
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 |
|
|
||||||
|
|||||||
@@ -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/ | |
|
| POST /openai/v1/audio/ | |
|
||||||
| transcriptions | |
|
| transcriptions | |
|
||||||
@@ -60,7 +60,7 @@ async function transcribe(blob) {
|
|||||||
form.append('language', 'it');
|
form.append('language', 'it');
|
||||||
|
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
'https://proxy.kube5s.ru/openai/v1/audio/transcriptions',
|
'https://lang.kube5s.ru/openai/v1/audio/transcriptions',
|
||||||
{ method: 'POST',
|
{ method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
||||||
body: form }
|
body: form }
|
||||||
@@ -129,7 +129,7 @@ const phonTimeout = new Promise(resolve =>
|
|||||||
/opt/groq-proxy/ws_server.py — WS сервер (:8766) БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ
|
/opt/groq-proxy/ws_server.py — WS сервер (:8766) БОЛЬШЕ НЕ ИСПОЛЬЗУЕТСЯ
|
||||||
/etc/nginx/conf.d/groq-proxy.conf
|
/etc/nginx/conf.d/groq-proxy.conf
|
||||||
|
|
||||||
Клиент (K8s capire.kube5s.ru):
|
Клиент (K8s lang.kube5s.ru):
|
||||||
~/lang/index.html — SPA
|
~/lang/index.html — SPA
|
||||||
~/lang/deploy_lang.sh — деплой
|
~/lang/deploy_lang.sh — деплой
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
Reference in New Issue
Block a user