v64: рефакторинг — разбил монолит на css/ js/ build.sh dist/ (781→43 строки index.html)
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Склеивает CSS/JS в единый index.html для деплоя (ConfigMap — один файл)."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
html = open(os.path.join(BASE, 'index.html')).read()
|
||||||
|
|
||||||
|
css = open(os.path.join(BASE, 'css/style.css')).read()
|
||||||
|
html = html.replace('<link rel="stylesheet" href="css/style.css">',
|
||||||
|
f'<style>\n{css}\n</style>')
|
||||||
|
|
||||||
|
for jsf in ['storage.js', 'syllables.js', 'transcribe.js', 'audio.js', 'compare.js', 'main.js']:
|
||||||
|
js = open(os.path.join(BASE, 'js', jsf)).read()
|
||||||
|
html = html.replace(f'<script src="js/{jsf}"></script>',
|
||||||
|
f'<script>\n{js}\n</script>')
|
||||||
|
|
||||||
|
os.makedirs(os.path.join(BASE, 'dist'), exist_ok=True)
|
||||||
|
with open(os.path.join(BASE, 'dist/index.html'), 'w') as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
print(f'dist/index.html ready ({len(html)} bytes)')
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* Lyngvo — Итальянский тренажёр произношения */
|
||||||
|
body { background: #f8f8fa; font-family: sans-serif; }
|
||||||
|
#app { max-width:900px; margin:20px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:24px 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; }
|
||||||
|
.syl-chip { cursor:pointer; padding:2px 4px; border-radius:4px; transition:0.15s; }
|
||||||
|
.syl-chip:hover { background:#e0e0ff; }
|
||||||
+5
-2
@@ -1,9 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Вклеиваем Groq API ключ в index.html
|
# Собираем dist/index.html (CSS + JS встроены)
|
||||||
|
~/lang/.venv/bin/python3 ~/lang/build.sh
|
||||||
|
|
||||||
|
# Вклеиваем Groq API ключ в dist/index.html
|
||||||
KEY=$(cat ~/lang/token.txt)
|
KEY=$(cat ~/lang/token.txt)
|
||||||
cp ~/lang/index.html /tmp/lyngvo_index.html
|
cp ~/lang/dist/index.html /tmp/lyngvo_index.html
|
||||||
sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html
|
sed -i "s|const GROQ_API_KEY = '';|const GROQ_API_KEY = '${KEY}';|" /tmp/lyngvo_index.html
|
||||||
|
|
||||||
# Синхронизация всей папки lang на ВМ
|
# Синхронизация всей папки lang на ВМ
|
||||||
|
|||||||
Vendored
+770
@@ -0,0 +1,770 @@
|
|||||||
|
<!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>
|
||||||
|
/* Lyngvo — Итальянский тренажёр произношения */
|
||||||
|
body { background: #f8f8fa; font-family: sans-serif; }
|
||||||
|
#app { max-width:900px; margin:20px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:24px 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; }
|
||||||
|
.syl-chip { cursor:pointer; padding:2px 4px; border-radius:4px; transition:0.15s; }
|
||||||
|
.syl-chip:hover { background:#e0e0ff; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1>
|
||||||
|
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
|
||||||
|
1. Введите фразу → 2. ▶ Эталон (прослушайте) → 3. 🎙 Записать (произнесите) → 4. 📊 Сравнить
|
||||||
|
</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>
|
||||||
|
// ---------- Хранилище (IndexedDB + localStorage) ----------
|
||||||
|
|
||||||
|
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 saveTranscription(id, text) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec) { rec.transcription = text; store.put(rec); }
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() });
|
||||||
|
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>' +
|
||||||
|
'<button onclick="compareRec(' + r.id + ')">📊</button>' +
|
||||||
|
(r.transcription ? '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' + syllabifyPhrase(r.transcription) + '</span>' : '') +
|
||||||
|
'<button onclick="deleteRec(' + r.id + ')" style="background:#e44;padding:4px 8px">🗑</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
tx.objectStore('recs').delete(id);
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} 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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Слоги ----------
|
||||||
|
|
||||||
|
function syllabifyIT(w) {
|
||||||
|
const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase());
|
||||||
|
const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s);
|
||||||
|
let out = [], syl = '', i = 0;
|
||||||
|
while (i < w.length) {
|
||||||
|
syl += w[i];
|
||||||
|
if (isV(w[i])) {
|
||||||
|
let j = i + 1, cons = '';
|
||||||
|
while (j < w.length && !isV(w[j])) cons += w[j++];
|
||||||
|
if (j < w.length && cons.length > 0) {
|
||||||
|
const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2);
|
||||||
|
syl += cons.slice(0, k);
|
||||||
|
out.push(syl); syl = '';
|
||||||
|
i += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (syl) out.push(syl);
|
||||||
|
return out.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syllabifyPhrase(text) {
|
||||||
|
return text.split(/\s+/).map(syllabifyIT).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AudioContext для проигрывания слогов ----------
|
||||||
|
let _audioCtx = null;
|
||||||
|
function getAudioCtx() {
|
||||||
|
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (_audioCtx.state === 'suspended') _audioCtx.resume();
|
||||||
|
return _audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _currentBlob = null;
|
||||||
|
let _currentSylBuf = null;
|
||||||
|
|
||||||
|
async function playSyllable(sylIdx, total) {
|
||||||
|
if (!_currentBlob) return;
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
try {
|
||||||
|
if (!_currentSylBuf) {
|
||||||
|
const ab = await _currentBlob.arrayBuffer();
|
||||||
|
_currentSylBuf = await ctx.decodeAudioData(ab);
|
||||||
|
}
|
||||||
|
const dur = _currentSylBuf.duration;
|
||||||
|
const start = (dur / total) * sylIdx;
|
||||||
|
const len = dur / total;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = _currentSylBuf;
|
||||||
|
src.connect(ctx.destination);
|
||||||
|
src.start(0, start, len);
|
||||||
|
} catch(e) { console.log('playSyllable ERR', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Groq API: транскрипция и перевод ----------
|
||||||
|
|
||||||
|
async function transcribe(blob) {
|
||||||
|
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
|
||||||
|
const tStart = performance.now();
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'audio.webm');
|
||||||
|
form.append('model', 'whisper-large-v3');
|
||||||
|
form.append('language', 'it');
|
||||||
|
|
||||||
|
console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
console.log('[HTTP] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
||||||
|
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
|
||||||
|
} catch(e) {
|
||||||
|
console.log('[HTTP] ERR', e);
|
||||||
|
return { text: '', error: 'network' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateToRussian(text) {
|
||||||
|
if (!GROQ_API_KEY) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://proxy.kube5s.ru/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) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||||
|
|
||||||
|
// ---------- Индикатор микрофона ----------
|
||||||
|
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 });
|
||||||
|
startMicMeter(stream);
|
||||||
|
audioChunks = [];
|
||||||
|
let mimeType = 'audio/webm;codecs=opus';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
|
mimeType = 'audio/webm';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = '';
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
else {
|
||||||
|
const t = document.getElementById('inputText').value.trim();
|
||||||
|
if (t) saveRecording(userAudioBlob, t, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
recordStartTime = Date.now();
|
||||||
|
setStatus('Идёт запись...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Воспроизведение ----------
|
||||||
|
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);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
||||||
|
audio.onerror = () => { URL.revokeObjectURL(url); reject(); };
|
||||||
|
audio.play();
|
||||||
|
});
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await playTTS(ttsText);
|
||||||
|
userAudio.play();
|
||||||
|
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Фонетический анализ ----------
|
||||||
|
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;
|
||||||
|
const frameSize = Math.floor(sr * 0.025);
|
||||||
|
const step = Math.floor(frameSize / 2);
|
||||||
|
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
|
||||||
|
|
||||||
|
const rmsArr = new Float32Array(numFrames);
|
||||||
|
const zcrArr = new Float32Array(numFrames);
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
const s = fi * step;
|
||||||
|
let rmsSum = 0, zcr = 0;
|
||||||
|
for (let i = s; i < s + frameSize; i++) {
|
||||||
|
rmsSum += data[i] * data[i];
|
||||||
|
if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++;
|
||||||
|
}
|
||||||
|
rmsArr[fi] = Math.sqrt(rmsSum / frameSize);
|
||||||
|
zcrArr[fi] = zcr / frameSize * sr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let meanRms = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
|
||||||
|
meanRms /= numFrames;
|
||||||
|
const energyThresh = Math.max(0.005, meanRms * 0.4);
|
||||||
|
|
||||||
|
const pitches = [];
|
||||||
|
let voicedCount = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) {
|
||||||
|
voicedCount++;
|
||||||
|
const p = pitchFrame(data, fi * step, frameSize, sr);
|
||||||
|
if (p > 70 && p < 450) pitches.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const voiceRatio = voicedCount / numFrames;
|
||||||
|
|
||||||
|
let pitchHz = 0, pitchStability = 0;
|
||||||
|
if (pitches.length >= 5) {
|
||||||
|
const sorted = [...pitches].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)];
|
||||||
|
const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)];
|
||||||
|
const relMad = (mad / median) * 100;
|
||||||
|
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
|
||||||
|
pitchHz = Math.round(median);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vzArr = [];
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
||||||
|
}
|
||||||
|
let articulationScore = 40;
|
||||||
|
if (vzArr.length > 0) {
|
||||||
|
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
||||||
|
articulationScore = Math.round(Math.max(0, Math.min(100,
|
||||||
|
mzc < 150 ? mzc / 1.5 :
|
||||||
|
mzc < 500 ? 60 + (mzc - 150) / 350 * 20 :
|
||||||
|
mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 :
|
||||||
|
mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 :
|
||||||
|
Math.max(0, 58 - (mzc - 3000) / 1000 * 20)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endStart = Math.floor(numFrames * 0.78);
|
||||||
|
let endVoiced = 0;
|
||||||
|
const endTotal = numFrames - endStart;
|
||||||
|
for (let fi = endStart; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) endVoiced++;
|
||||||
|
}
|
||||||
|
const endingScore = endTotal > 0 ? Math.round(Math.min(100, (endVoiced / endTotal) * 125)) : 50;
|
||||||
|
|
||||||
|
const clarityScore = Math.round(
|
||||||
|
voiceRatio < 0.05 ? 0 :
|
||||||
|
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
||||||
|
voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 :
|
||||||
|
voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 :
|
||||||
|
Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSpeech = voiceRatio > 0.08;
|
||||||
|
const phonScore = hasSpeech ? Math.round(
|
||||||
|
pitchStability * 0.30 +
|
||||||
|
articulationScore * 0.25 +
|
||||||
|
clarityScore * 0.25 +
|
||||||
|
endingScore * 0.20
|
||||||
|
) : 0;
|
||||||
|
|
||||||
|
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
||||||
|
} catch (err) {
|
||||||
|
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.30 ? sr / bestLag : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Сравнение текстов (Левенштейн) и doCompare ----------
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
if (maxLen === 0) return 1;
|
||||||
|
return 1 - levenshtein(a, b) / 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;
|
||||||
|
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 doCompare(blob, originalText, duration) {
|
||||||
|
if (isAnalyzing) return '';
|
||||||
|
isAnalyzing = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
setStatus('Анализ... ' + elapsed + 'с');
|
||||||
|
}, 150);
|
||||||
|
let whisperResult, phon;
|
||||||
|
try {
|
||||||
|
const phonTimeout = new Promise(resolve =>
|
||||||
|
setTimeout(() => resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 }), 15000)
|
||||||
|
);
|
||||||
|
[whisperResult, phon] = await Promise.all([
|
||||||
|
transcribe(blob),
|
||||||
|
Promise.race([phoneticAnalysis(blob), phonTimeout])
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
isAnalyzing = false;
|
||||||
|
setStatus('Ошибка анализа.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
clearInterval(timerId);
|
||||||
|
|
||||||
|
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
||||||
|
let recHTML = '', diffHTML = '';
|
||||||
|
|
||||||
|
if (whisperResult.text) {
|
||||||
|
const _transcribed = whisperResult.text;
|
||||||
|
const { score, words } = compareTexts(originalText, whisperResult.text);
|
||||||
|
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
||||||
|
recHTML = recEmoji + ' Распознание: <b>' + score + '%</b> | 🗣 <i>' + whisperResult.text + '</i> | ';
|
||||||
|
diffHTML =
|
||||||
|
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
||||||
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
|
'</div>' +
|
||||||
|
renderDiff(words);
|
||||||
|
} else {
|
||||||
|
recHTML = '';
|
||||||
|
diffHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('score').innerHTML =
|
||||||
|
recHTML + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
||||||
|
|
||||||
|
document.getElementById('diff').innerHTML = diffHTML +
|
||||||
|
'<div class="phonetics">' +
|
||||||
|
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
||||||
|
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
||||||
|
'<span>🗣 Артикуляция: ' + phon.voiceClarity + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Слоги — кликабельные
|
||||||
|
_currentBlob = blob;
|
||||||
|
_currentSylBuf = null;
|
||||||
|
const sy = syllabifyIT(originalText);
|
||||||
|
if (sy && sy.includes('-')) {
|
||||||
|
const syls = sy.split('-');
|
||||||
|
const sylHTML = '<div style="margin-top:8px;font-size:1.2em;letter-spacing:2px">' +
|
||||||
|
syls.map((s, i) =>
|
||||||
|
'<span class="syl-chip" onclick="playSyllable(' + i + ',' + syls.length + ')">' + s + '</span>'
|
||||||
|
).join('<span style="color:#ccc">·</span>') +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('diff').innerHTML += sylHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Готово.');
|
||||||
|
isAnalyzing = false;
|
||||||
|
return whisperResult.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
|
const GROQ_API_KEY = '';
|
||||||
|
const VERSION = 'v64';
|
||||||
|
|
||||||
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
|
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||||
|
let recordStartTime = 0, recordDuration = 0;
|
||||||
|
|
||||||
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
|
function enable(id, val) { document.getElementById(id).disabled = !val; }
|
||||||
|
|
||||||
|
// ---------- Инициализация ----------
|
||||||
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
renderHistory();
|
||||||
|
renderRecHistory();
|
||||||
|
|
||||||
|
// ---------- Кнопка Запись ----------
|
||||||
|
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('Ошибка воспроизведения — нажмите ещё раз.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Кнопки ----------
|
||||||
|
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();
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnStereo').onclick = async () => {
|
||||||
|
const text = document.getElementById('inputText').value.trim();
|
||||||
|
if (!text) return setStatus('Введите текст для эталона!');
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
||||||
|
await playStereo(text, userAudioBlob);
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- История: compareRec / stereoRec ----------
|
||||||
|
async function compareRec(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) {
|
||||||
|
document.getElementById('inputText').value = rec.word;
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
document.getElementById('inputText').value = rec.word;
|
||||||
|
await playStereo(rec.word, rec.blob);
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+8
-745
@@ -7,21 +7,7 @@
|
|||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>Lyngvo — Итальянский тренажёр произношения</title>
|
<title>Lyngvo — Итальянский тренажёр произношения</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<link rel="stylesheet" href="css/style.css">
|
||||||
body { background: #f8f8fa; font-family: sans-serif; }
|
|
||||||
#app { max-width:900px; margin:20px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:24px 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -47,735 +33,12 @@
|
|||||||
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></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 id="recHistory" style="margin-top:16px;font-size:0.85em"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
const GROQ_API_KEY = '';
|
|
||||||
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
|
||||||
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
|
||||||
let recordStartTime = 0, recordDuration = 0;
|
|
||||||
// ---------- Версия ----------
|
|
||||||
const VERSION = 'v63';
|
|
||||||
document.getElementById('ver').textContent = VERSION;
|
|
||||||
|
|
||||||
// ---------- AudioContext для проигрывания слогов ----------
|
<script src="js/storage.js"></script>
|
||||||
let _audioCtx = null;
|
<script src="js/syllables.js"></script>
|
||||||
function getAudioCtx() {
|
<script src="js/transcribe.js"></script>
|
||||||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
<script src="js/audio.js"></script>
|
||||||
if (_audioCtx.state === 'suspended') _audioCtx.resume();
|
<script src="js/compare.js"></script>
|
||||||
return _audioCtx;
|
<script src="js/main.js"></script>
|
||||||
}
|
|
||||||
|
|
||||||
let _currentBlob = null; // blob для проигрывания слогов
|
|
||||||
let _currentSylBuf = null; // декодированный AudioBuffer
|
|
||||||
|
|
||||||
async function playSyllable(sylIdx, total) {
|
|
||||||
if (!_currentBlob) return;
|
|
||||||
const ctx = getAudioCtx();
|
|
||||||
try {
|
|
||||||
if (!_currentSylBuf) {
|
|
||||||
const ab = await _currentBlob.arrayBuffer();
|
|
||||||
_currentSylBuf = await ctx.decodeAudioData(ab);
|
|
||||||
}
|
|
||||||
const dur = _currentSylBuf.duration;
|
|
||||||
const start = (dur / total) * sylIdx;
|
|
||||||
const len = dur / total;
|
|
||||||
const src = ctx.createBufferSource();
|
|
||||||
src.buffer = _currentSylBuf;
|
|
||||||
src.connect(ctx.destination);
|
|
||||||
src.start(0, start, len);
|
|
||||||
} catch(e) { console.log('playSyllable ERR', e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Перевод на русский (Groq) ----------
|
|
||||||
async function translateToRussian(text) {
|
|
||||||
if (!GROQ_API_KEY) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://proxy.kube5s.ru/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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syllabifyIT(w) {
|
|
||||||
const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase());
|
|
||||||
const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s);
|
|
||||||
let out = [], syl = '', i = 0;
|
|
||||||
while (i < w.length) {
|
|
||||||
syl += w[i];
|
|
||||||
if (isV(w[i])) {
|
|
||||||
let j = i + 1, cons = '';
|
|
||||||
while (j < w.length && !isV(w[j])) cons += w[j++];
|
|
||||||
if (j < w.length && cons.length > 0) {
|
|
||||||
const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2);
|
|
||||||
syl += cons.slice(0, k);
|
|
||||||
out.push(syl); syl = '';
|
|
||||||
i += k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (syl) out.push(syl);
|
|
||||||
return out.join('-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function syllabifyPhrase(text) {
|
|
||||||
return text.split(/\s+/).map(syllabifyIT).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTranscription(id, text) {
|
|
||||||
try {
|
|
||||||
const db = await openRecDB();
|
|
||||||
const tx = db.transaction('recs', 'readwrite');
|
|
||||||
const store = tx.objectStore('recs');
|
|
||||||
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
|
||||||
if (rec) { rec.transcription = text; store.put(rec); }
|
|
||||||
await new Promise(r => { tx.oncomplete = r; });
|
|
||||||
renderRecHistory();
|
|
||||||
} catch(e) { /* тихо */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
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>' +
|
|
||||||
'<button onclick="compareRec(' + r.id + ')">📊</button>' +
|
|
||||||
(r.transcription ? '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' + syllabifyPhrase(r.transcription) + '</span>' : '') +
|
|
||||||
'<button onclick="deleteRec(' + r.id + ')" style="background:#e44;padding:4px 8px">🗑</button>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRec(id) {
|
|
||||||
try {
|
|
||||||
const db = await openRecDB();
|
|
||||||
const tx = db.transaction('recs', 'readwrite');
|
|
||||||
tx.objectStore('recs').delete(id);
|
|
||||||
await new Promise(r => { tx.oncomplete = r; });
|
|
||||||
renderRecHistory();
|
|
||||||
} catch(e) { /* тихо */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compareRec(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) {
|
|
||||||
document.getElementById('inputText').value = rec.word;
|
|
||||||
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
|
||||||
if (tr) saveTranscription(id, tr);
|
|
||||||
}
|
|
||||||
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
document.getElementById('inputText').value = rec.word;
|
|
||||||
await playStereo(rec.word, rec.blob);
|
|
||||||
// после стерео — сравнение
|
|
||||||
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
|
||||||
if (tr) saveTranscription(id, tr);
|
|
||||||
}
|
|
||||||
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doCompare(blob, originalText, duration) {
|
|
||||||
if (isAnalyzing) return '';
|
|
||||||
isAnalyzing = true;
|
|
||||||
const startTime = Date.now();
|
|
||||||
const timerId = setInterval(() => {
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
setStatus('Анализ... ' + elapsed + 'с');
|
|
||||||
}, 150);
|
|
||||||
let whisperResult, phon;
|
|
||||||
try {
|
|
||||||
const phonTimeout = new Promise(resolve =>
|
|
||||||
setTimeout(() => resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 }), 15000)
|
|
||||||
);
|
|
||||||
[whisperResult, phon] = await Promise.all([
|
|
||||||
transcribe(blob),
|
|
||||||
Promise.race([phoneticAnalysis(blob), phonTimeout])
|
|
||||||
]);
|
|
||||||
} catch(e) {
|
|
||||||
clearInterval(timerId);
|
|
||||||
isAnalyzing = false;
|
|
||||||
setStatus('Ошибка анализа.');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
clearInterval(timerId);
|
|
||||||
|
|
||||||
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
|
||||||
let recHTML = '', diffHTML = '';
|
|
||||||
|
|
||||||
if (whisperResult.text) {
|
|
||||||
const _transcribed = whisperResult.text;
|
|
||||||
const { score, words } = compareTexts(originalText, whisperResult.text);
|
|
||||||
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
|
||||||
recHTML = recEmoji + ' Распознание: <b>' + score + '%</b> | 🗣 <i>' + whisperResult.text + '</i> | ';
|
|
||||||
diffHTML =
|
|
||||||
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
|
||||||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
|
||||||
'</div>' +
|
|
||||||
renderDiff(words);
|
|
||||||
} else {
|
|
||||||
recHTML = '';
|
|
||||||
diffHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('score').innerHTML =
|
|
||||||
recHTML + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
|
||||||
|
|
||||||
document.getElementById('diff').innerHTML = diffHTML +
|
|
||||||
'<div class="phonetics">' +
|
|
||||||
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
|
||||||
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
|
||||||
'<span>� Артикуляция: ' + phon.voiceClarity + '%</span>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
// Слоги — кликабельные для прослушивания фрагментов
|
|
||||||
_currentBlob = blob;
|
|
||||||
_currentSylBuf = null;
|
|
||||||
const sy = syllabifyIT(originalText);
|
|
||||||
if (sy && sy.includes('-')) {
|
|
||||||
const syls = sy.split('-');
|
|
||||||
const sylHTML = '<div style="margin-top:8px;font-size:1.2em;letter-spacing:2px">' +
|
|
||||||
syls.map((s, i) =>
|
|
||||||
'<span class="syl-chip" data-syl="' + i + '" data-total="' + syls.length + '"' +
|
|
||||||
' style="cursor:pointer;padding:2px 4px;border-radius:4px;transition:0.15s"' +
|
|
||||||
' onmouseover="this.style.background=\'#e0e0ff\'"' +
|
|
||||||
' onmouseout="this.style.background=\'\'"' +
|
|
||||||
' onclick="playSyllable(' + i + ',' + syls.length + ')">' + s + '</span>'
|
|
||||||
).join('<span style="color:#ccc">·</span>') +
|
|
||||||
'</div>';
|
|
||||||
document.getElementById('diff').innerHTML += sylHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('Готово.');
|
|
||||||
isAnalyzing = false;
|
|
||||||
return whisperResult.text || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
|
||||||
audio.onerror = () => { URL.revokeObjectURL(url); reject(); };
|
|
||||||
audio.play();
|
|
||||||
});
|
|
||||||
// после воспроизведения — сравнение
|
|
||||||
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
|
||||||
if (tr) saveTranscription(id, tr);
|
|
||||||
}
|
|
||||||
} 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();
|
|
||||||
renderRecHistory();
|
|
||||||
|
|
||||||
// ---------- Фонетический анализ ----------
|
|
||||||
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;
|
|
||||||
|
|
||||||
const frameSize = Math.floor(sr * 0.025); // 25ms
|
|
||||||
const step = Math.floor(frameSize / 2); // 12.5ms, 50% overlap
|
|
||||||
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
|
|
||||||
|
|
||||||
// Покадровые: RMS-энергия и ZCR
|
|
||||||
const rmsArr = new Float32Array(numFrames);
|
|
||||||
const zcrArr = new Float32Array(numFrames);
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) {
|
|
||||||
const s = fi * step;
|
|
||||||
let rmsSum = 0, zcr = 0;
|
|
||||||
for (let i = s; i < s + frameSize; i++) {
|
|
||||||
rmsSum += data[i] * data[i];
|
|
||||||
if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++;
|
|
||||||
}
|
|
||||||
rmsArr[fi] = Math.sqrt(rmsSum / frameSize);
|
|
||||||
zcrArr[fi] = zcr / frameSize * sr; // пересечений/сек
|
|
||||||
}
|
|
||||||
|
|
||||||
// Адаптивный порог энергии (40% от среднего RMS)
|
|
||||||
let meanRms = 0;
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
|
|
||||||
meanRms /= numFrames;
|
|
||||||
const energyThresh = Math.max(0.005, meanRms * 0.4);
|
|
||||||
|
|
||||||
// Детекция voiced-кадров + питч (только на voiced)
|
|
||||||
const pitches = [];
|
|
||||||
let voicedCount = 0;
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) {
|
|
||||||
if (rmsArr[fi] > energyThresh) {
|
|
||||||
voicedCount++;
|
|
||||||
const p = pitchFrame(data, fi * step, frameSize, sr);
|
|
||||||
if (p > 70 && p < 450) pitches.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const voiceRatio = voicedCount / numFrames;
|
|
||||||
|
|
||||||
// 1. Стабильность питча — MAD (robust к выбросам, реалистичный диапазон)
|
|
||||||
let pitchHz = 0, pitchStability = 0;
|
|
||||||
if (pitches.length >= 5) {
|
|
||||||
const sorted = [...pitches].sort((a, b) => a - b);
|
|
||||||
const median = sorted[Math.floor(sorted.length / 2)];
|
|
||||||
const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)];
|
|
||||||
const relMad = (mad / median) * 100; // % от медианы
|
|
||||||
// <8% отлично, 8-20% хорошо, 20-35% приемлемо, >42% плохо
|
|
||||||
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
|
|
||||||
pitchHz = Math.round(median);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Артикуляция — ZCR voiced-кадров (чёткость согласных)
|
|
||||||
const vzArr = [];
|
|
||||||
for (let fi = 0; fi < numFrames; fi++) {
|
|
||||||
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
|
||||||
}
|
|
||||||
let articulationScore = 40;
|
|
||||||
if (vzArr.length > 0) {
|
|
||||||
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
|
||||||
// Итальянская речь: 300-1800 ZCR/сек — норма для voiced
|
|
||||||
articulationScore = Math.round(Math.max(0, Math.min(100,
|
|
||||||
mzc < 150 ? mzc / 1.5 :
|
|
||||||
mzc < 500 ? 60 + (mzc - 150) / 350 * 20 :
|
|
||||||
mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 :
|
|
||||||
mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 :
|
|
||||||
Math.max(0, 58 - (mzc - 3000) / 1000 * 20)
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Качество окончания (последние 22% записи)
|
|
||||||
// Итальянские слова заканчиваются на гласную → конец должен быть voiced
|
|
||||||
const endStart = Math.floor(numFrames * 0.78);
|
|
||||||
let endVoiced = 0;
|
|
||||||
const endTotal = numFrames - endStart;
|
|
||||||
for (let fi = endStart; fi < numFrames; fi++) {
|
|
||||||
if (rmsArr[fi] > energyThresh) endVoiced++;
|
|
||||||
}
|
|
||||||
const endingScore = endTotal > 0
|
|
||||||
? Math.round(Math.min(100, (endVoiced / endTotal) * 125))
|
|
||||||
: 50;
|
|
||||||
|
|
||||||
// 4. Ясность голоса (оптимальный voiceRatio 0.45-0.70)
|
|
||||||
const clarityScore = Math.round(
|
|
||||||
voiceRatio < 0.05 ? 0 :
|
|
||||||
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
|
||||||
voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 :
|
|
||||||
voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 :
|
|
||||||
Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Итоговый балл
|
|
||||||
const hasSpeech = voiceRatio > 0.08;
|
|
||||||
const phonScore = hasSpeech ? Math.round(
|
|
||||||
pitchStability * 0.30 +
|
|
||||||
articulationScore * 0.25 +
|
|
||||||
clarityScore * 0.25 +
|
|
||||||
endingScore * 0.20
|
|
||||||
) : 0;
|
|
||||||
|
|
||||||
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
|
||||||
} catch (err) {
|
|
||||||
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.30 ? sr / bestLag : 0; // порог (energy pre-filter снижает ложные срабатывания)
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
else {
|
|
||||||
const t = document.getElementById('inputText').value.trim();
|
|
||||||
if (t) saveRecording(userAudioBlob, t, 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('Ошибка воспроизведения — нажмите ещё раз.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// после воспроизведения — сравнение
|
|
||||||
const original = document.getElementById('inputText').value.trim();
|
|
||||||
if (original && userAudioBlob) {
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function transcribe(blob) {
|
|
||||||
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
|
|
||||||
const tStart = performance.now();
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', blob, 'audio.webm');
|
|
||||||
form.append('model', 'whisper-large-v3');
|
|
||||||
form.append('language', 'it');
|
|
||||||
|
|
||||||
console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
const data = await r.json();
|
|
||||||
console.log('[HTTP] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
|
||||||
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
|
|
||||||
} catch(e) {
|
|
||||||
console.log('[HTTP] ERR', e);
|
|
||||||
return { text: '', error: 'network' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
};
|
|
||||||
document.getElementById('btnStereo').onclick = async () => {
|
|
||||||
const text = document.getElementById('inputText').value.trim();
|
|
||||||
if (!text) return setStatus('Введите текст для эталона!');
|
|
||||||
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
|
||||||
await playStereo(text, userAudioBlob);
|
|
||||||
// после стерео — сравнение
|
|
||||||
const original = document.getElementById('inputText').value.trim();
|
|
||||||
if (original && userAudioBlob) {
|
|
||||||
await doCompare(userAudioBlob, original, recordDuration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
// ---------- Аудио: запись, TTS, фонетический анализ ----------
|
||||||
|
|
||||||
|
// ---------- Индикатор микрофона ----------
|
||||||
|
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 });
|
||||||
|
startMicMeter(stream);
|
||||||
|
audioChunks = [];
|
||||||
|
let mimeType = 'audio/webm;codecs=opus';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
|
mimeType = 'audio/webm';
|
||||||
|
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = '';
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
else {
|
||||||
|
const t = document.getElementById('inputText').value.trim();
|
||||||
|
if (t) saveRecording(userAudioBlob, t, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecorder.start(100);
|
||||||
|
recordStartTime = Date.now();
|
||||||
|
setStatus('Идёт запись...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Воспроизведение ----------
|
||||||
|
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);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
||||||
|
audio.onerror = () => { URL.revokeObjectURL(url); reject(); };
|
||||||
|
audio.play();
|
||||||
|
});
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await playTTS(ttsText);
|
||||||
|
userAudio.play();
|
||||||
|
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Фонетический анализ ----------
|
||||||
|
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;
|
||||||
|
const frameSize = Math.floor(sr * 0.025);
|
||||||
|
const step = Math.floor(frameSize / 2);
|
||||||
|
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
|
||||||
|
|
||||||
|
const rmsArr = new Float32Array(numFrames);
|
||||||
|
const zcrArr = new Float32Array(numFrames);
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
const s = fi * step;
|
||||||
|
let rmsSum = 0, zcr = 0;
|
||||||
|
for (let i = s; i < s + frameSize; i++) {
|
||||||
|
rmsSum += data[i] * data[i];
|
||||||
|
if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++;
|
||||||
|
}
|
||||||
|
rmsArr[fi] = Math.sqrt(rmsSum / frameSize);
|
||||||
|
zcrArr[fi] = zcr / frameSize * sr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let meanRms = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
|
||||||
|
meanRms /= numFrames;
|
||||||
|
const energyThresh = Math.max(0.005, meanRms * 0.4);
|
||||||
|
|
||||||
|
const pitches = [];
|
||||||
|
let voicedCount = 0;
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) {
|
||||||
|
voicedCount++;
|
||||||
|
const p = pitchFrame(data, fi * step, frameSize, sr);
|
||||||
|
if (p > 70 && p < 450) pitches.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const voiceRatio = voicedCount / numFrames;
|
||||||
|
|
||||||
|
let pitchHz = 0, pitchStability = 0;
|
||||||
|
if (pitches.length >= 5) {
|
||||||
|
const sorted = [...pitches].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)];
|
||||||
|
const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)];
|
||||||
|
const relMad = (mad / median) * 100;
|
||||||
|
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
|
||||||
|
pitchHz = Math.round(median);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vzArr = [];
|
||||||
|
for (let fi = 0; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
|
||||||
|
}
|
||||||
|
let articulationScore = 40;
|
||||||
|
if (vzArr.length > 0) {
|
||||||
|
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
|
||||||
|
articulationScore = Math.round(Math.max(0, Math.min(100,
|
||||||
|
mzc < 150 ? mzc / 1.5 :
|
||||||
|
mzc < 500 ? 60 + (mzc - 150) / 350 * 20 :
|
||||||
|
mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 :
|
||||||
|
mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 :
|
||||||
|
Math.max(0, 58 - (mzc - 3000) / 1000 * 20)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endStart = Math.floor(numFrames * 0.78);
|
||||||
|
let endVoiced = 0;
|
||||||
|
const endTotal = numFrames - endStart;
|
||||||
|
for (let fi = endStart; fi < numFrames; fi++) {
|
||||||
|
if (rmsArr[fi] > energyThresh) endVoiced++;
|
||||||
|
}
|
||||||
|
const endingScore = endTotal > 0 ? Math.round(Math.min(100, (endVoiced / endTotal) * 125)) : 50;
|
||||||
|
|
||||||
|
const clarityScore = Math.round(
|
||||||
|
voiceRatio < 0.05 ? 0 :
|
||||||
|
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
|
||||||
|
voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 :
|
||||||
|
voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 :
|
||||||
|
Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasSpeech = voiceRatio > 0.08;
|
||||||
|
const phonScore = hasSpeech ? Math.round(
|
||||||
|
pitchStability * 0.30 +
|
||||||
|
articulationScore * 0.25 +
|
||||||
|
clarityScore * 0.25 +
|
||||||
|
endingScore * 0.20
|
||||||
|
) : 0;
|
||||||
|
|
||||||
|
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
|
||||||
|
} catch (err) {
|
||||||
|
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.30 ? sr / bestLag : 0;
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// ---------- Сравнение текстов (Левенштейн) и doCompare ----------
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const maxLen = Math.max(a.length, b.length);
|
||||||
|
if (maxLen === 0) return 1;
|
||||||
|
return 1 - levenshtein(a, b) / 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;
|
||||||
|
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 doCompare(blob, originalText, duration) {
|
||||||
|
if (isAnalyzing) return '';
|
||||||
|
isAnalyzing = true;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timerId = setInterval(() => {
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
setStatus('Анализ... ' + elapsed + 'с');
|
||||||
|
}, 150);
|
||||||
|
let whisperResult, phon;
|
||||||
|
try {
|
||||||
|
const phonTimeout = new Promise(resolve =>
|
||||||
|
setTimeout(() => resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 }), 15000)
|
||||||
|
);
|
||||||
|
[whisperResult, phon] = await Promise.all([
|
||||||
|
transcribe(blob),
|
||||||
|
Promise.race([phoneticAnalysis(blob), phonTimeout])
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
isAnalyzing = false;
|
||||||
|
setStatus('Ошибка анализа.');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
clearInterval(timerId);
|
||||||
|
|
||||||
|
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
|
||||||
|
let recHTML = '', diffHTML = '';
|
||||||
|
|
||||||
|
if (whisperResult.text) {
|
||||||
|
const _transcribed = whisperResult.text;
|
||||||
|
const { score, words } = compareTexts(originalText, whisperResult.text);
|
||||||
|
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
|
||||||
|
recHTML = recEmoji + ' Распознание: <b>' + score + '%</b> | 🗣 <i>' + whisperResult.text + '</i> | ';
|
||||||
|
diffHTML =
|
||||||
|
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
||||||
|
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <50%' +
|
||||||
|
'</div>' +
|
||||||
|
renderDiff(words);
|
||||||
|
} else {
|
||||||
|
recHTML = '';
|
||||||
|
diffHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('score').innerHTML =
|
||||||
|
recHTML + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
|
||||||
|
|
||||||
|
document.getElementById('diff').innerHTML = diffHTML +
|
||||||
|
'<div class="phonetics">' +
|
||||||
|
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
|
||||||
|
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
|
||||||
|
'<span>🗣 Артикуляция: ' + phon.voiceClarity + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// Слоги — кликабельные
|
||||||
|
_currentBlob = blob;
|
||||||
|
_currentSylBuf = null;
|
||||||
|
const sy = syllabifyIT(originalText);
|
||||||
|
if (sy && sy.includes('-')) {
|
||||||
|
const syls = sy.split('-');
|
||||||
|
const sylHTML = '<div style="margin-top:8px;font-size:1.2em;letter-spacing:2px">' +
|
||||||
|
syls.map((s, i) =>
|
||||||
|
'<span class="syl-chip" onclick="playSyllable(' + i + ',' + syls.length + ')">' + s + '</span>'
|
||||||
|
).join('<span style="color:#ccc">·</span>') +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('diff').innerHTML += sylHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Готово.');
|
||||||
|
isAnalyzing = false;
|
||||||
|
return whisperResult.text || '';
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
// ---------- Глобальные переменные и инициализация ----------
|
||||||
|
const GROQ_API_KEY = '';
|
||||||
|
const VERSION = 'v64';
|
||||||
|
|
||||||
|
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
|
||||||
|
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
|
||||||
|
let recordStartTime = 0, recordDuration = 0;
|
||||||
|
|
||||||
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||||
|
function enable(id, val) { document.getElementById(id).disabled = !val; }
|
||||||
|
|
||||||
|
// ---------- Инициализация ----------
|
||||||
|
document.getElementById('ver').textContent = VERSION;
|
||||||
|
renderHistory();
|
||||||
|
renderRecHistory();
|
||||||
|
|
||||||
|
// ---------- Кнопка Запись ----------
|
||||||
|
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('Ошибка воспроизведения — нажмите ещё раз.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Кнопки ----------
|
||||||
|
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();
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btnStereo').onclick = async () => {
|
||||||
|
const text = document.getElementById('inputText').value.trim();
|
||||||
|
if (!text) return setStatus('Введите текст для эталона!');
|
||||||
|
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
|
||||||
|
await playStereo(text, userAudioBlob);
|
||||||
|
const original = document.getElementById('inputText').value.trim();
|
||||||
|
if (original && userAudioBlob) {
|
||||||
|
await doCompare(userAudioBlob, original, recordDuration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- История: compareRec / stereoRec ----------
|
||||||
|
async function compareRec(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) {
|
||||||
|
document.getElementById('inputText').value = rec.word;
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
document.getElementById('inputText').value = rec.word;
|
||||||
|
await playStereo(rec.word, rec.blob);
|
||||||
|
const tr = await doCompare(rec.blob, rec.word, rec.duration);
|
||||||
|
if (tr) saveTranscription(id, tr);
|
||||||
|
}
|
||||||
|
} catch(e) { setStatus('Ошибка загрузки записи.'); }
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
// ---------- Хранилище (IndexedDB + localStorage) ----------
|
||||||
|
|
||||||
|
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 saveTranscription(id, text) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
const store = tx.objectStore('recs');
|
||||||
|
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
|
||||||
|
if (rec) { rec.transcription = text; store.put(rec); }
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} catch(e) { /* тихо */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() });
|
||||||
|
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>' +
|
||||||
|
'<button onclick="compareRec(' + r.id + ')">📊</button>' +
|
||||||
|
(r.transcription ? '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' + syllabifyPhrase(r.transcription) + '</span>' : '') +
|
||||||
|
'<button onclick="deleteRec(' + r.id + ')" style="background:#e44;padding:4px 8px">🗑</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRec(id) {
|
||||||
|
try {
|
||||||
|
const db = await openRecDB();
|
||||||
|
const tx = db.transaction('recs', 'readwrite');
|
||||||
|
tx.objectStore('recs').delete(id);
|
||||||
|
await new Promise(r => { tx.oncomplete = r; });
|
||||||
|
renderRecHistory();
|
||||||
|
} 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('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// ---------- Слоги ----------
|
||||||
|
|
||||||
|
function syllabifyIT(w) {
|
||||||
|
const isV = c => 'aeiouàèéìíòóùú'.includes(c.toLowerCase());
|
||||||
|
const onset = s => /^(str|scr|spr|spl|bl|br|cl|cr|dr|fl|fr|gl|gn|gr|pl|pr|sc|sk|sl|sm|sn|sp|sq|sr|st|sv|tr|ch|gh)/i.test(s);
|
||||||
|
let out = [], syl = '', i = 0;
|
||||||
|
while (i < w.length) {
|
||||||
|
syl += w[i];
|
||||||
|
if (isV(w[i])) {
|
||||||
|
let j = i + 1, cons = '';
|
||||||
|
while (j < w.length && !isV(w[j])) cons += w[j++];
|
||||||
|
if (j < w.length && cons.length > 0) {
|
||||||
|
const k = cons.length === 1 ? 0 : onset(cons.slice(1)) ? 1 : Math.floor(cons.length / 2);
|
||||||
|
syl += cons.slice(0, k);
|
||||||
|
out.push(syl); syl = '';
|
||||||
|
i += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (syl) out.push(syl);
|
||||||
|
return out.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syllabifyPhrase(text) {
|
||||||
|
return text.split(/\s+/).map(syllabifyIT).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- AudioContext для проигрывания слогов ----------
|
||||||
|
let _audioCtx = null;
|
||||||
|
function getAudioCtx() {
|
||||||
|
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (_audioCtx.state === 'suspended') _audioCtx.resume();
|
||||||
|
return _audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _currentBlob = null;
|
||||||
|
let _currentSylBuf = null;
|
||||||
|
|
||||||
|
async function playSyllable(sylIdx, total) {
|
||||||
|
if (!_currentBlob) return;
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
try {
|
||||||
|
if (!_currentSylBuf) {
|
||||||
|
const ab = await _currentBlob.arrayBuffer();
|
||||||
|
_currentSylBuf = await ctx.decodeAudioData(ab);
|
||||||
|
}
|
||||||
|
const dur = _currentSylBuf.duration;
|
||||||
|
const start = (dur / total) * sylIdx;
|
||||||
|
const len = dur / total;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = _currentSylBuf;
|
||||||
|
src.connect(ctx.destination);
|
||||||
|
src.start(0, start, len);
|
||||||
|
} catch(e) { console.log('playSyllable ERR', e); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// ---------- Groq API: транскрипция и перевод ----------
|
||||||
|
|
||||||
|
async function transcribe(blob) {
|
||||||
|
if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
|
||||||
|
const tStart = performance.now();
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', blob, 'audio.webm');
|
||||||
|
form.append('model', 'whisper-large-v3');
|
||||||
|
form.append('language', 'it');
|
||||||
|
|
||||||
|
console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://proxy.kube5s.ru/openai/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + GROQ_API_KEY },
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
console.log('[HTTP] ← ' + ((performance.now()-tStart)/1000).toFixed(2) + 's → ' + (data.text||'(empty)'));
|
||||||
|
return data.error ? { text: '', error: data.error } : { text: data.text || '', error: null };
|
||||||
|
} catch(e) {
|
||||||
|
console.log('[HTTP] ERR', e);
|
||||||
|
return { text: '', error: 'network' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateToRussian(text) {
|
||||||
|
if (!GROQ_API_KEY) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://proxy.kube5s.ru/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) { /* тихо */ }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user