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