v19: перевод, история записей (IndexedDB), стерео из истории, фонетический анализ, индикатор микрофона
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Правила
|
||||||
|
|
||||||
|
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
|
||||||
|
- Вопрос → короткий ответ → СТОП.
|
||||||
|
- Ничего лишнего.
|
||||||
|
- Код — только по запросу.
|
||||||
|
|
||||||
|
## ⛔⛔⛔ ВОПРОС = СТОП
|
||||||
|
|
||||||
|
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
|
||||||
|
1. Только ответить на вопрос
|
||||||
|
2. ОСТАНОВИТЬСЯ
|
||||||
|
3. ЖДАТЬ следующей команды
|
||||||
|
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
||||||
|
|
||||||
|
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
|
||||||
|
|
||||||
|
**НИКАКИХ самодеятельных изменений рабочего кода:**
|
||||||
|
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
|
||||||
|
- Никаких новых фич без явного разрешения
|
||||||
|
- Никаких helm upgrade, kubectl patch и прочих инфраструктурных изменений без команды
|
||||||
|
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
|
||||||
|
|
||||||
|
**ПРЕЦЕДЕНТЫ:**
|
||||||
|
- v1.3.49: убрали mode toggle в edit modal "для улучшения" → сломали рабочий редактор
|
||||||
|
- helm upgrade попытка → сломал JWT secret router → 401 у всех функций пользователей
|
||||||
|
|
||||||
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
|
2. Файлы редактируются локально:
|
||||||
|
~/lang
|
||||||
|
|
||||||
|
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
|
~/lang/ \
|
||||||
|
naeel@5.172.178.213:~/terra/lang/
|
||||||
|
|
||||||
|
|
||||||
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/lang
|
||||||
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
|
|
||||||
|
- не выполнять инфраструктурные команды локально
|
||||||
|
- не открывать интерактивные сессии
|
||||||
|
- не делать цепочки без необходимости
|
||||||
|
|
||||||
|
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
|
||||||
|
|
||||||
|
5. ЗАПРЕЩЕНО:
|
||||||
|
- откатывать код
|
||||||
|
- менять версии
|
||||||
|
- ломать рабочее состояние
|
||||||
|
|
||||||
|
6. После каждого исправления:
|
||||||
|
- git add/commit ЛОКАЛЬНО
|
||||||
|
- затем синхронизация (rsync) на ВМ
|
||||||
|
|
||||||
|
7. ~/lang/test-results/
|
||||||
|
|
||||||
|
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
|
||||||
|
|
||||||
|
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
|
||||||
|
- Не расширять масштаб работы
|
||||||
|
- Не выполнять "логичные следующие шаги"
|
||||||
|
- Не инициировать дополнительные операции
|
||||||
|
- Не делать ничего кроме того что сказано
|
||||||
|
|
||||||
|
**Пример (2026-05-01):**
|
||||||
|
- Приказано: "собери"
|
||||||
|
- Сделано: ✓ собрал образы v1.3.17 и v0.1.2
|
||||||
|
- СТОП — жду команды дальше
|
||||||
|
- **ЗАПРЕЩЕНО:** обновлять манифесты, заливать образы, применять на кластер, запускать тесты
|
||||||
|
|
||||||
|
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Правила работы агента
|
||||||
|
|
||||||
|
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
||||||
|
|
||||||
|
1. УВЕЛИЧИТЬ ТЕГ в `deploy/lang.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||||
|
2. rsync на ВМ
|
||||||
|
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
|
||||||
|
4. docker build с НОВЫМ тегом
|
||||||
|
5. docker push с НОВЫМ тегом
|
||||||
|
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
|
||||||
|
|
||||||
|
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
|
||||||
|
|
||||||
|
## Файловая система (актуально)
|
||||||
|
|
||||||
|
1. Все файлы редактируются локально: `~/lang`
|
||||||
|
2. После любых изменений — обязательно rsync на ВМ:
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
|
~/lang/ \
|
||||||
|
naeel@5.172.178.213:~/terra/lang/
|
||||||
|
|
||||||
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/lang
|
||||||
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||||
|
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||||
|
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||||
|
7. Только rsync для синхронизации
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
1. Редактируешь локально
|
||||||
|
2. rsync на ВМ
|
||||||
|
3. Выполняешь команды через SSH на ВМ
|
||||||
|
|
||||||
|
## SSH
|
||||||
|
|
||||||
|
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- `doc/thinking/` — лог рассуждений агента (обязательно)
|
||||||
|
- `doc/progress.md` — трекер задач
|
||||||
|
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
||||||
|
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/lang`. НИКОГДА через SSH на VM.
|
||||||
|
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
|
||||||
|
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
|
||||||
|
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
||||||
|
|
||||||
|
Версионирование тегами: `vMAJOR.MINOR.PATCH`
|
||||||
|
- Patch — любое изменение кода
|
||||||
|
- Minor — новая фича / компонент
|
||||||
|
- Major — breaking change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag vX.Y.Z && git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
||||||
|
|
||||||
|
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||||
|
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
||||||
|
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
||||||
|
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
||||||
|
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
||||||
|
|
||||||
|
## Документация тест-прогонов — В РЕАЛЬНОМ ВРЕМЕНИ
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
1. Перед запуском `run_all.sh` — создать файл `test-results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
|
||||||
|
2. Запускать `run_all.sh 2>&1 | tee ~/terra/lang/test-results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
|
||||||
|
3. После завершения — rsync лога локально. Лог остаётся как документация.
|
||||||
|
4. Папка `test-results/` в репозитории — `.gitignore` не добавлять, логи коммитить.
|
||||||
|
|
||||||
|
**Формат запуска:**
|
||||||
|
```bash
|
||||||
|
LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
|
"bash ~/terra/lang/scripts/run_all.sh 2>&1 | tee ~/terra/lang/${LOG}"
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
|
naeel@5.172.178.213:~/terra/lang/test-results/ ~/lang/test-results/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|
||||||
|
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
||||||
|
|
||||||
|
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
||||||
|
- Все изменения — только через код (Helm chart, YAML, Go-код) + сборка + деплой.
|
||||||
|
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
||||||
|
- Исключение: только если пользователь явно написал "примени ручной патч".
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
- Не трогать рабочий код без явного указания
|
||||||
|
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
|
||||||
|
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
|
||||||
|
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||||
|
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
token.txt
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# 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. Инструкция по деплою выше
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "--- PODS ---"
|
||||||
|
kubectl get pods -n default | grep lyngvo || true
|
||||||
|
|
||||||
|
echo "\n--- SERVICES ---"
|
||||||
|
kubectl get svc -n default | grep lyngvo || true
|
||||||
|
|
||||||
|
echo "\n--- INGRESS ---"
|
||||||
|
kubectl get ingress -n default | grep lyngvo || true
|
||||||
|
|
||||||
|
echo "\n--- POD LOGS (последние 50 строк) ---"
|
||||||
|
kubectl logs deploy/lyngvo -n default --tail=50 || true
|
||||||
|
|
||||||
|
echo "\n--- DESCRIBE INGRESS ---"
|
||||||
|
kubectl describe ingress lyngvo -n default || true
|
||||||
|
|
||||||
|
echo "\n--- DESCRIBE SERVICE ---"
|
||||||
|
kubectl describe svc lyngvo -n default || true
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Вклеиваем Groq API ключ в index.html
|
||||||
|
KEY=$(cat ~/lang/token.txt)
|
||||||
|
cp ~/lang/index.html /tmp/lyngvo_index.html
|
||||||
|
sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html
|
||||||
|
|
||||||
|
# Синхронизация всей папки lang на ВМ
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" ~/lang/ naeel@5.172.178.213:~/terra/lang/
|
||||||
|
|
||||||
|
# Деплой на ВМ (index.html с ключом — через временный файл)
|
||||||
|
scp -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 /tmp/lyngvo_index.html naeel@5.172.178.213:~/terra/lang/index.html
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 '
|
||||||
|
cd ~/terra/lang && \
|
||||||
|
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 - && \
|
||||||
|
kubectl apply -f k8s/deployment.yaml && \
|
||||||
|
kubectl apply -f k8s/ingress.yaml && \
|
||||||
|
kubectl rollout restart deploy/lyngvo -n default && \
|
||||||
|
kubectl rollout status deploy/lyngvo -n default --timeout=60s
|
||||||
|
'
|
||||||
|
|
||||||
|
rm -f /tmp/lyngvo_index.html
|
||||||
|
echo "\n=== Деплой завершён. Проверяй capire.kube5s.ru ==="
|
||||||
+556
@@ -0,0 +1,556 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Lyngvo — Итальянский тренажёр произношения</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { background: #f8f8fa; font-family: sans-serif; }
|
||||||
|
#app { max-width:600px; margin:40px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:32px; }
|
||||||
|
textarea { width:100%; font-size:1.2em; border-radius:6px; border:1px solid #ccc; padding:8px; }
|
||||||
|
button { font-size:1em; padding:8px 18px; border-radius:6px; border:none; background:#2a7cff; color:#fff; cursor:pointer; }
|
||||||
|
button:disabled { background:#ccc; cursor:not-allowed; }
|
||||||
|
#score { font-size:1.5em; margin:12px 0; }
|
||||||
|
#diff { font-size:1.2em; line-height:1.8; }
|
||||||
|
#history { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
|
||||||
|
#history span { font-size:0.85em; background:#e8e8f0; padding:3px 10px; border-radius:12px; cursor:pointer; }
|
||||||
|
#history span:hover { background:#d0d0e0; }
|
||||||
|
.phonetics { font-size:0.85em; color:#666; margin-top:6px; display:flex; gap:16px; flex-wrap:wrap; }
|
||||||
|
.recItem { display:flex; align-items:center; gap:8px; padding:4px 8px; background:#f0f0f5; border-radius:8px; margin:4px 0; }
|
||||||
|
.recItem button { font-size:0.8em; padding:4px 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span></h1>
|
||||||
|
<div style="font-size:0.75em;color:#999;margin-bottom:12px">тренажёр итальянского произношения · для Google Chrome</div>
|
||||||
|
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
||||||
|
<div id="history"></div>
|
||||||
|
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
||||||
|
<button id="btnTTS">▶ Эталон</button>
|
||||||
|
<button id="btnRecord">🎙 Записать</button>
|
||||||
|
<button id="btnPlayUser" disabled>🔊 Ваш голос</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="micBar" style="height:12px; background:#e0e0e0; border-radius:6px; margin:8px 0; overflow:hidden; display:none">
|
||||||
|
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||||||
|
</div>
|
||||||
|
<div id="score"></div>
|
||||||
|
<div id="diff"></div>
|
||||||
|
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
|
||||||
|
<div id="recHistory" style="margin-top:16px;font-size:0.85em"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const GROQ_API_KEY = '';
|
||||||
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
|
let isRecording = false, micAnalyser = null, micAnimId = null;
|
||||||
|
let recordStartTime = 0, recordDuration = 0;
|
||||||
|
// ---------- Версия ----------
|
||||||
|
const VERSION = 'v19';
|
||||||
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
|
||||||
|
// ---------- Перевод на русский (Groq) ----------
|
||||||
|
async function translateToRussian(text) {
|
||||||
|
if (!GROQ_API_KEY) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama-3.3-70b-versatile',
|
||||||
|
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
|
||||||
|
max_tokens: 50,
|
||||||
|
temperature: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.choices?.[0]?.message?.content) {
|
||||||
|
document.getElementById('translation').textContent = '🇷🇺 ' + data.choices[0].message.content;
|
||||||
|
}
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- История записей (IndexedDB) ----------
|
||||||
|
function openRecDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open('lyngvo_recs', 1);
|
||||||
|
req.onupgradeneeded = () => { req.result.createObjectStore('recs', { keyPath: 'id', autoIncrement: true }); };
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecording(blob, word, duration) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
store.add({ word, duration, blob, ts: Date.now() });
|
||||||
|
// Удаляем старые если > 10
|
||||||
|
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
||||||
|
if (all.length > 10) {
|
||||||
|
const toDelete = all.sort((a,b) => a.ts - b.ts).slice(0, all.length - 10);
|
||||||
|
for (const rec of toDelete) store.delete(rec.id);
|
||||||
|
}
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readonly');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
|
||||||
|
return all.sort((a,b) => b.ts - a.ts);
|
||||||
|
} catch(e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRecHistory() {
|
||||||
|
const recs = await loadRecordings();
|
||||||
|
const div = document.getElementById('recHistory');
|
||||||
|
if (!recs.length) { div.innerHTML = ''; return; }
|
||||||
|
div.innerHTML = '<div style="color:#999;margin-bottom:4px">📼 История записей (выберите для стерео):</div>' +
|
||||||
|
recs.map(r => {
|
||||||
|
const d = new Date(r.ts);
|
||||||
|
const time = d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||||||
|
return '<div class="recItem">' +
|
||||||
|
'<span style="cursor:pointer" onclick="document.getElementById(\'inputText\').value=\'' +
|
||||||
|
r.word.replace(/'/g, "\\'") + '\';ttsText=\'' + r.word.replace(/'/g, "\\'") + '\'">' + r.word + '</span>' +
|
||||||
|
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
|
||||||
|
'<button onclick="playRec(' + r.id + ')">▶</button>' +
|
||||||
|
'<button onclick="stereoRec(' + r.id + ')">🎧</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stereoRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readonly');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec?.blob) {
|
||||||
|
const text = rec.word;
|
||||||
|
document.getElementById('inputText').value = text;
|
||||||
|
ttsText = text;
|
||||||
|
await playStereo(text, rec.blob);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readonly');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec?.blob) {
|
||||||
|
const url = URL.createObjectURL(rec.blob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.onended = () => URL.revokeObjectURL(url);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- История фраз (localStorage) ----------
|
||||||
|
const HIST_KEY = 'lyngvo_phrases';
|
||||||
|
function loadHistory() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
function saveToHistory(text) {
|
||||||
|
let hist = loadHistory().filter(t => t !== text);
|
||||||
|
hist.unshift(text);
|
||||||
|
hist = hist.slice(0, 5);
|
||||||
|
localStorage.setItem(HIST_KEY, JSON.stringify(hist));
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
function renderHistory() {
|
||||||
|
const hist = loadHistory();
|
||||||
|
const div = document.getElementById('history');
|
||||||
|
if (!hist.length) { div.innerHTML = ''; return; }
|
||||||
|
div.innerHTML = hist.map(t =>
|
||||||
|
'<span onclick="var v=\'' + t.replace(/'/g, "\\'") +
|
||||||
|
'\';document.getElementById(\'inputText\').value=v;ttsText=v;renderHistory()">' + t + '</span>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
renderHistory();
|
||||||
|
|
||||||
|
// ---------- Фонетический анализ (pitch, стабильность, чистота) ----------
|
||||||
|
function phoneticAnalysis(blob) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
|
||||||
|
const buf = await ctx.decodeAudioData(e.target.result);
|
||||||
|
const data = buf.getChannelData(0);
|
||||||
|
const sr = buf.sampleRate;
|
||||||
|
const len = data.length;
|
||||||
|
|
||||||
|
// Pitch по кадрам с 50% перекрытием
|
||||||
|
const frame = Math.floor(sr * 0.03); // 30ms
|
||||||
|
const step = Math.floor(frame / 2);
|
||||||
|
const pitches = [];
|
||||||
|
for (let i = 0; i + frame < len; i += step) {
|
||||||
|
const p = pitchFrame(data, i, frame, sr);
|
||||||
|
if (p > 70 && p < 400) pitches.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pitchMean = 0, pitchStd = 0, pitchScore = 0;
|
||||||
|
const totalFrames = Math.max(1, Math.floor(len / step));
|
||||||
|
|
||||||
|
if (pitches.length >= 3) {
|
||||||
|
pitchMean = pitches.reduce((a,b) => a+b, 0) / pitches.length;
|
||||||
|
pitchStd = Math.sqrt(pitches.reduce((s,p) => s + (p-pitchMean)**2, 0) / pitches.length);
|
||||||
|
const cv = (pitchStd / pitchMean) * 100;
|
||||||
|
// Базовые 25 баллов за наличие голоса + бонус за стабильность
|
||||||
|
pitchScore = Math.round(25 + Math.max(0, Math.min(75, 75 - cv * 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доля озвученных кадров
|
||||||
|
const voiceRatio = pitches.length / totalFrames;
|
||||||
|
const clarityScore = Math.round(Math.min(100, voiceRatio * 130));
|
||||||
|
|
||||||
|
// Если речь есть, даём минимальный базовый балл
|
||||||
|
const hasSpeech = voiceRatio > 0.05;
|
||||||
|
const phonScore = hasSpeech
|
||||||
|
? Math.round(pitchScore * 0.5 + clarityScore * 0.5)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
pitchHz: Math.round(pitchMean),
|
||||||
|
pitchStability: pitchScore,
|
||||||
|
voiceClarity: clarityScore,
|
||||||
|
phonScore
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нормированная автокорреляция для одного кадра
|
||||||
|
function pitchFrame(data, start, len, sr) {
|
||||||
|
const n = len;
|
||||||
|
let e0 = 0;
|
||||||
|
for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i];
|
||||||
|
if (e0 < 1e-8) return 0;
|
||||||
|
|
||||||
|
const minLag = Math.floor(sr / 400);
|
||||||
|
const maxLag = Math.min(n - 1, Math.floor(sr / 70));
|
||||||
|
let bestR = 0, bestLag = 0;
|
||||||
|
|
||||||
|
for (let lag = minLag; lag <= maxLag; lag++) {
|
||||||
|
let num = 0, e1 = 0, e2 = 0;
|
||||||
|
const m = n - lag;
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const a = data[start + i], b = data[start + i + lag];
|
||||||
|
num += a * b;
|
||||||
|
e1 += a * a;
|
||||||
|
e2 += b * b;
|
||||||
|
}
|
||||||
|
const den = Math.sqrt(e1 * e2);
|
||||||
|
const r = den > 0 ? num / den : 0;
|
||||||
|
if (r > bestR) { bestR = r; bestLag = lag; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestR > 0.22 ? sr / bestLag : 0; // смягчённый порог
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
|
function enable(id, val) { document.getElementById(id).disabled = !val; }
|
||||||
|
|
||||||
|
// ---------- Индикатор микрофона ----------
|
||||||
|
function startMicMeter(stream) {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
micAnalyser = ctx.createAnalyser();
|
||||||
|
micAnalyser.fftSize = 256;
|
||||||
|
src.connect(micAnalyser);
|
||||||
|
const bar = document.getElementById('micBar');
|
||||||
|
const fill = document.getElementById('micFill');
|
||||||
|
bar.style.display = 'block';
|
||||||
|
fill.style.width = '0%';
|
||||||
|
const data = new Uint8Array(micAnalyser.frequencyBinCount);
|
||||||
|
function tick() {
|
||||||
|
if (!micAnalyser) return;
|
||||||
|
micAnalyser.getByteFrequencyData(data);
|
||||||
|
const max = Math.max(...data);
|
||||||
|
const pct = Math.min(100, Math.round(max / 255 * 100));
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50';
|
||||||
|
micAnimId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Mic meter:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMicMeter() {
|
||||||
|
if (micAnimId) cancelAnimationFrame(micAnimId);
|
||||||
|
micAnimId = null;
|
||||||
|
micAnalyser = null;
|
||||||
|
document.getElementById('micBar').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- TTS ----------
|
||||||
|
function playTTS(text) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const utter = new SpeechSynthesisUtterance(text);
|
||||||
|
utter.lang = 'it-IT';
|
||||||
|
utter.rate = 0.9;
|
||||||
|
const voices = speechSynthesis.getVoices();
|
||||||
|
const it = voices.find(v => v.lang.startsWith('it'));
|
||||||
|
if (it) utter.voice = it;
|
||||||
|
utter.onend = resolve;
|
||||||
|
utter.onerror = resolve;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
speechSynthesis.speak(utter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Запись ----------
|
||||||
|
async function startRecording() {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
// Индикатор запускаем ПОСЛЕ получения stream
|
||||||
|
startMicMeter(stream);
|
||||||
|
audioChunks = [];
|
||||||
|
// Проверяем поддержку кодеков
|
||||||
|
let mimeType = 'audio/webm;codecs=opus';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
|
mimeType = 'audio/webm';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
|
mimeType = ''; // default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const opts = mimeType ? { mimeType } : {};
|
||||||
|
mediaRecorder = new MediaRecorder(stream, opts);
|
||||||
|
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
stopMicMeter();
|
||||||
|
recordDuration = (Date.now() - recordStartTime) / 1000;
|
||||||
|
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
enable('btnCompare', true);
|
||||||
|
enable('btnStereo', true);
|
||||||
|
enable('btnPlayUser', true);
|
||||||
|
const btn = document.getElementById('btnRecord');
|
||||||
|
btn.textContent = '🎙 Записать';
|
||||||
|
btn.disabled = false;
|
||||||
|
isRecording = false;
|
||||||
|
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
|
||||||
|
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
|
||||||
|
};
|
||||||
|
mediaRecorder.start(100); // собираем чанки каждые 100ms
|
||||||
|
recordStartTime = Date.now();
|
||||||
|
setStatus('Идёт запись...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Кнопка Запись ----------
|
||||||
|
document.getElementById('btnRecord').onclick = async function () {
|
||||||
|
const btn = this;
|
||||||
|
if (isRecording) {
|
||||||
|
btn.disabled = true;
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isRecording = true;
|
||||||
|
btn.textContent = '■ Стоп';
|
||||||
|
await startRecording();
|
||||||
|
} catch (err) {
|
||||||
|
let msg = '⛔ ';
|
||||||
|
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Нажмите на замочек 🔒 в адресной строке → Микрофон → Разрешить.';
|
||||||
|
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден. Подключите микрофон.';
|
||||||
|
else if (err.name === 'NotReadableError') msg += 'Микрофон занят другим приложением.';
|
||||||
|
else msg += 'Ошибка: ' + (err.message || err.name);
|
||||||
|
setStatus(msg);
|
||||||
|
btn.textContent = '🎙 Записать';
|
||||||
|
isRecording = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Воспроизведение своей записи ----------
|
||||||
|
document.getElementById('btnPlayUser').onclick = async () => {
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи!');
|
||||||
|
if (userAudioBlob.size === 0) return setStatus('Запись пустая!');
|
||||||
|
const url = URL.createObjectURL(userAudioBlob);
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audio.volume = 1.0;
|
||||||
|
audio.onended = () => { URL.revokeObjectURL(url); setStatus('Готово.'); };
|
||||||
|
audio.onerror = () => { URL.revokeObjectURL(url); setStatus('Ошибка воспроизведения.'); };
|
||||||
|
setStatus('🔊 Ваша запись...');
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
} catch (err) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus('Ошибка воспроизведения — нажмите ещё раз.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function transcribe(blob) {
|
||||||
|
if (!GROQ_API_KEY) { setStatus('⛔ Нет API ключа Groq!'); return ''; }
|
||||||
|
setStatus('Отправка на Whisper...');
|
||||||
|
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');
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 30000);
|
||||||
|
const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
setStatus('⛔ Whisper: ' + (data.error.message || 'Forbidden — нужен VPN или новый ключ'));
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return data.text || '';
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') setStatus('⛔ Whisper: таймаут (30с). Проверьте VPN/интернет.');
|
||||||
|
else setStatus('⛔ Whisper: ' + (err.message || 'сетевая ошибка. Нужен VPN?'));
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return text.toLowerCase().replace(/[.,!?;:«»"'’]/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Нечёткое сравнение (Левенштейн) ----------
|
||||||
|
function levenshtein(a, b) {
|
||||||
|
const m = a.length, n = b.length;
|
||||||
|
const dp = Array.from({length: m+1}, (_,i) => [i]);
|
||||||
|
for (let j=0; j<=n; j++) dp[0][j] = j;
|
||||||
|
for (let i=1; i<=m; i++)
|
||||||
|
for (let j=1; j<=n; j++)
|
||||||
|
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
|
||||||
|
return dp[m][n];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordSimilarity(a, b) {
|
||||||
|
// 0..1 где 1 = идентично
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
if (maxLen === 0) return 1;
|
||||||
|
const dist = levenshtein(a, b);
|
||||||
|
return 1 - dist / maxLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTexts(original, transcribed) {
|
||||||
|
const a = normalize(original).split(/\s+/);
|
||||||
|
const b = normalize(transcribed).split(/\s+/);
|
||||||
|
let totalScore = 0;
|
||||||
|
const result = a.map(word => {
|
||||||
|
let best = 0, bestMatch = '';
|
||||||
|
for (const t of b) {
|
||||||
|
const sim = wordSimilarity(word, t);
|
||||||
|
if (sim > best) { best = sim; bestMatch = t; }
|
||||||
|
}
|
||||||
|
totalScore += best;
|
||||||
|
// ok=зелёный (≥80%), near=жёлтый (≥50%), bad=красный
|
||||||
|
const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad';
|
||||||
|
return { word, quality, bestMatch, score: Math.round(best*100) };
|
||||||
|
});
|
||||||
|
const score = Math.round((totalScore / a.length) * 100);
|
||||||
|
return { score, words: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiff(words) {
|
||||||
|
return words.map(({ word, quality, bestMatch, score }) => {
|
||||||
|
const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red';
|
||||||
|
const tip = quality !== 'ok' ? ` → ${bestMatch} (${score}%)` : '';
|
||||||
|
return `<span style="color:${color}; font-weight:bold">${word}</span><span style="color:#888; font-size:0.85em">${tip}</span>`;
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playStereo(ttsText, userBlob) {
|
||||||
|
// Простой вариант: параллельно TTS и user audio, pan через Web Audio API
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
// User audio
|
||||||
|
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
|
||||||
|
await playTTS(ttsText);
|
||||||
|
userAudio.play();
|
||||||
|
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnTTS').onclick = async () => {
|
||||||
|
ttsText = document.getElementById('inputText').value.trim();
|
||||||
|
if (!ttsText) return setStatus('Введите текст!');
|
||||||
|
saveToHistory(ttsText);
|
||||||
|
translateToRussian(ttsText);
|
||||||
|
setStatus('🔊 Эталон...');
|
||||||
|
await playTTS(ttsText);
|
||||||
|
setStatus('Готово.');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnCompare').onclick = async () => {
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи!');
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
setStatus('Анализ...');
|
||||||
|
const [transcribed, phon] = await Promise.all([
|
||||||
|
transcribe(userAudioBlob),
|
||||||
|
phoneticAnalysis(userAudioBlob)
|
||||||
|
]);
|
||||||
|
if (!transcribed) return setStatus('Whisper не распознал речь.');
|
||||||
|
const { score, words } = compareTexts(original, transcribed);
|
||||||
|
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
||||||
|
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
||||||
|
|
||||||
|
document.getElementById('score').innerHTML =
|
||||||
|
recEmoji + ' Распознавание: <b>' + score + '%</b>' +
|
||||||
|
' | ' + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
||||||
|
|
||||||
|
document.getElementById('diff').innerHTML =
|
||||||
|
'<div style="margin-bottom:6px;color:#666;font-size:0.9em">' +
|
||||||
|
'🗣 Whisper: <b>' + transcribed + '</b> · ⏱ ' + recordDuration.toFixed(1) + 'с' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="phonetics">' +
|
||||||
|
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
||||||
|
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
||||||
|
'<span>🔊 Чистота: ' + phon.voiceClarity + '%</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
||||||
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
|
'</div>' +
|
||||||
|
renderDiff(words);
|
||||||
|
setStatus('Готово.');
|
||||||
|
};
|
||||||
|
document.getElementById('btnStereo').onclick = async () => {
|
||||||
|
const text = ttsText || document.getElementById('inputText').value.trim();
|
||||||
|
if (!text) return setStatus('Введите текст для эталона!');
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
||||||
|
await playStereo(text, userAudioBlob);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
nginx.ingress.kubernetes.io/hsts: "true"
|
||||||
|
nginx.ingress.kubernetes.io/hsts-max-age: "31536000"
|
||||||
|
nginx.ingress.kubernetes.io/hsts-include-subdomains: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- capire.kube5s.ru
|
||||||
|
secretName: capire-tls
|
||||||
|
rules:
|
||||||
|
- host: capire.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: lyngvo
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name lang.kube5s.ru;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Агрессивный запрет кеширования — Chrome HSTS/cache bypass
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||||
|
add_header Pragma "no-cache" always;
|
||||||
|
add_header Expires "0" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user