Files
lang/HANDOFF_LYNGVO_v2.md
T

13 KiB
Raw Blame History

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)

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

Порядок реализации

  1. index.html — полный рабочий код (TTS + запись + Whisper + diff + стерео)
  2. nginx.conf
  3. k8s/deployment.yaml + k8s/ingress.yaml
  4. Инструкция по деплою выше