13 KiB
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
Пользовательский сценарий (строго в этом порядке)
- Пользователь вводит итальянское слово/фразу в поле
- Нажимает "Воспроизвести эталон" → браузер синтезирует речь итальянским голосом
- Нажимает "Записать" → идёт запись с микрофона (кнопка меняется на "Стоп")
- Нажимает "Стоп" → запись останавливается
- Нажимает "Сравнить" → запись уходит на Whisper API, приходит транскрипция
- На экране:
- Similarity score (число от 0 до 100%)
- Diff: слова оригинала подсвечены зелёным (совпало) / красным (не совпало)
- Нажимает "Стерео-воспроизведение" → левое ухо: TTS-эталон, правое ухо: голос пользователя
Шаг 1 — TTS (Web Speech API)
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)
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-кода
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 — Сравнение текстов
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.
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 (минимальный, но понятный)
<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
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
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
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)
# 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
Порядок реализации
index.html— полный рабочий код (TTS + запись + Whisper + diff + стерео)nginx.confk8s/deployment.yaml+k8s/ingress.yaml- Инструкция по деплою выше