# 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