Files
lang/HANDOFF_LYNGVO_v2.md

393 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Инструкция по деплою выше