393 lines
13 KiB
Markdown
393 lines
13 KiB
Markdown
# 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. Инструкция по деплою выше
|