v19: перевод, история записей (IndexedDB), стерео из истории, фонетический анализ, индикатор микрофона

This commit is contained in:
“Naeel”
2026-05-22 11:10:39 +04:00
commit 62907230c6
10 changed files with 1266 additions and 0 deletions
+556
View File
@@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Lyngvo — Итальянский тренажёр произношения</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background: #f8f8fa; font-family: sans-serif; }
#app { max-width:600px; margin:40px auto; background:#fff; border-radius:12px; box-shadow:0 2px 12px #0001; padding:32px; }
textarea { width:100%; font-size:1.2em; border-radius:6px; border:1px solid #ccc; padding:8px; }
button { font-size:1em; padding:8px 18px; border-radius:6px; border:none; background:#2a7cff; color:#fff; cursor:pointer; }
button:disabled { background:#ccc; cursor:not-allowed; }
#score { font-size:1.5em; margin:12px 0; }
#diff { font-size:1.2em; line-height:1.8; }
#history { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
#history span { font-size:0.85em; background:#e8e8f0; padding:3px 10px; border-radius:12px; cursor:pointer; }
#history span:hover { background:#d0d0e0; }
.phonetics { font-size:0.85em; color:#666; margin-top:6px; display:flex; gap:16px; flex-wrap:wrap; }
.recItem { display:flex; align-items:center; gap:8px; padding:4px 8px; background:#f0f0f5; border-radius:8px; margin:4px 0; }
.recItem button { font-size:0.8em; padding:4px 10px; }
</style>
</head>
<body>
<div id="app">
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span></h1>
<div style="font-size:0.75em;color:#999;margin-bottom:12px">тренажёр итальянского произношения · для Google Chrome</div>
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
<div id="history"></div>
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
<button id="btnTTS">▶ Эталон</button>
<button id="btnRecord">🎙 Записать</button>
<button id="btnPlayUser" disabled>🔊 Ваш голос</button>
<button id="btnCompare" disabled>📊 Сравнить</button>
<button id="btnStereo" disabled>🎧 Стерео</button>
</div>
<div id="status" style="color:gray; font-size:0.9em"></div>
<div id="micBar" style="height:12px; background:#e0e0e0; border-radius:6px; margin:8px 0; overflow:hidden; display:none">
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
</div>
<div id="score"></div>
<div id="diff"></div>
<div id="translation" style="font-size:0.9em;color:#555;margin-top:8px"></div>
<div id="recHistory" style="margin-top:16px;font-size:0.85em"></div>
</div>
<script>
const GROQ_API_KEY = '';
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
let isRecording = false, micAnalyser = null, micAnimId = null;
let recordStartTime = 0, recordDuration = 0;
// ---------- Версия ----------
const VERSION = 'v19';
document.getElementById('ver').textContent = VERSION;
// ---------- Перевод на русский (Groq) ----------
async function translateToRussian(text) {
if (!GROQ_API_KEY) return;
try {
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama-3.3-70b-versatile',
messages: [{ role: 'user', content: 'Переведи на русский одним словом или фразой: ' + text }],
max_tokens: 50,
temperature: 0
})
});
const data = await res.json();
if (data.choices?.[0]?.message?.content) {
document.getElementById('translation').textContent = '🇷🇺 ' + data.choices[0].message.content;
}
} catch(e) { /* тихо */ }
}
// ---------- История записей (IndexedDB) ----------
function openRecDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('lyngvo_recs', 1);
req.onupgradeneeded = () => { req.result.createObjectStore('recs', { keyPath: 'id', autoIncrement: true }); };
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveRecording(blob, word, duration) {
try {
const db = await openRecDB();
const tx = db.transaction('recs', 'readwrite');
const store = tx.objectStore('recs');
store.add({ word, duration, blob, ts: Date.now() });
// Удаляем старые если > 10
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
if (all.length > 10) {
const toDelete = all.sort((a,b) => a.ts - b.ts).slice(0, all.length - 10);
for (const rec of toDelete) store.delete(rec.id);
}
await new Promise(r => { tx.oncomplete = r; });
renderRecHistory();
} catch(e) { /* тихо */ }
}
async function loadRecordings() {
try {
const db = await openRecDB();
const tx = db.transaction('recs', 'readonly');
const store = tx.objectStore('recs');
const all = await new Promise(r => { const req = store.getAll(); req.onsuccess = () => r(req.result); });
return all.sort((a,b) => b.ts - a.ts);
} catch(e) { return []; }
}
async function renderRecHistory() {
const recs = await loadRecordings();
const div = document.getElementById('recHistory');
if (!recs.length) { div.innerHTML = ''; return; }
div.innerHTML = '<div style="color:#999;margin-bottom:4px">📼 История записей (выберите для стерео):</div>' +
recs.map(r => {
const d = new Date(r.ts);
const time = d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
return '<div class="recItem">' +
'<span style="cursor:pointer" onclick="document.getElementById(\'inputText\').value=\'' +
r.word.replace(/'/g, "\\'") + '\';ttsText=\'' + r.word.replace(/'/g, "\\'") + '\'">' + r.word + '</span>' +
'<span style="color:#999">' + time + ' · ' + r.duration.toFixed(1) + 'с</span>' +
'<button onclick="playRec(' + r.id + ')">▶</button>' +
'<button onclick="stereoRec(' + r.id + ')">🎧</button>' +
'</div>';
}).join('');
}
async function stereoRec(id) {
try {
const db = await openRecDB();
const tx = db.transaction('recs', 'readonly');
const store = tx.objectStore('recs');
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
if (rec?.blob) {
const text = rec.word;
document.getElementById('inputText').value = text;
ttsText = text;
await playStereo(text, rec.blob);
}
} catch(e) { setStatus('Ошибка загрузки записи.'); }
}
async function playRec(id) {
try {
const db = await openRecDB();
const tx = db.transaction('recs', 'readonly');
const store = tx.objectStore('recs');
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
if (rec?.blob) {
const url = URL.createObjectURL(rec.blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play();
}
} catch(e) { /* тихо */ }
}
// ---------- История фраз (localStorage) ----------
const HIST_KEY = 'lyngvo_phrases';
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HIST_KEY)) || []; }
catch { return []; }
}
function saveToHistory(text) {
let hist = loadHistory().filter(t => t !== text);
hist.unshift(text);
hist = hist.slice(0, 5);
localStorage.setItem(HIST_KEY, JSON.stringify(hist));
renderHistory();
}
function renderHistory() {
const hist = loadHistory();
const div = document.getElementById('history');
if (!hist.length) { div.innerHTML = ''; return; }
div.innerHTML = hist.map(t =>
'<span onclick="var v=\'' + t.replace(/'/g, "\\'") +
'\';document.getElementById(\'inputText\').value=v;ttsText=v;renderHistory()">' + t + '</span>'
).join('');
}
renderHistory();
// ---------- Фонетический анализ (pitch, стабильность, чистота) ----------
function phoneticAnalysis(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
const buf = await ctx.decodeAudioData(e.target.result);
const data = buf.getChannelData(0);
const sr = buf.sampleRate;
const len = data.length;
// Pitch по кадрам с 50% перекрытием
const frame = Math.floor(sr * 0.03); // 30ms
const step = Math.floor(frame / 2);
const pitches = [];
for (let i = 0; i + frame < len; i += step) {
const p = pitchFrame(data, i, frame, sr);
if (p > 70 && p < 400) pitches.push(p);
}
let pitchMean = 0, pitchStd = 0, pitchScore = 0;
const totalFrames = Math.max(1, Math.floor(len / step));
if (pitches.length >= 3) {
pitchMean = pitches.reduce((a,b) => a+b, 0) / pitches.length;
pitchStd = Math.sqrt(pitches.reduce((s,p) => s + (p-pitchMean)**2, 0) / pitches.length);
const cv = (pitchStd / pitchMean) * 100;
// Базовые 25 баллов за наличие голоса + бонус за стабильность
pitchScore = Math.round(25 + Math.max(0, Math.min(75, 75 - cv * 4)));
}
// Доля озвученных кадров
const voiceRatio = pitches.length / totalFrames;
const clarityScore = Math.round(Math.min(100, voiceRatio * 130));
// Если речь есть, даём минимальный базовый балл
const hasSpeech = voiceRatio > 0.05;
const phonScore = hasSpeech
? Math.round(pitchScore * 0.5 + clarityScore * 0.5)
: 0;
resolve({
pitchHz: Math.round(pitchMean),
pitchStability: pitchScore,
voiceClarity: clarityScore,
phonScore
});
} catch(e) {
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
}
};
reader.readAsArrayBuffer(blob);
});
}
// Нормированная автокорреляция для одного кадра
function pitchFrame(data, start, len, sr) {
const n = len;
let e0 = 0;
for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i];
if (e0 < 1e-8) return 0;
const minLag = Math.floor(sr / 400);
const maxLag = Math.min(n - 1, Math.floor(sr / 70));
let bestR = 0, bestLag = 0;
for (let lag = minLag; lag <= maxLag; lag++) {
let num = 0, e1 = 0, e2 = 0;
const m = n - lag;
for (let i = 0; i < m; i++) {
const a = data[start + i], b = data[start + i + lag];
num += a * b;
e1 += a * a;
e2 += b * b;
}
const den = Math.sqrt(e1 * e2);
const r = den > 0 ? num / den : 0;
if (r > bestR) { bestR = r; bestLag = lag; }
}
return bestR > 0.22 ? sr / bestLag : 0; // смягчённый порог
}
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function enable(id, val) { document.getElementById(id).disabled = !val; }
// ---------- Индикатор микрофона ----------
function startMicMeter(stream) {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
micAnalyser = ctx.createAnalyser();
micAnalyser.fftSize = 256;
src.connect(micAnalyser);
const bar = document.getElementById('micBar');
const fill = document.getElementById('micFill');
bar.style.display = 'block';
fill.style.width = '0%';
const data = new Uint8Array(micAnalyser.frequencyBinCount);
function tick() {
if (!micAnalyser) return;
micAnalyser.getByteFrequencyData(data);
const max = Math.max(...data);
const pct = Math.min(100, Math.round(max / 255 * 100));
fill.style.width = pct + '%';
fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50';
micAnimId = requestAnimationFrame(tick);
}
tick();
} catch(e) {
console.warn('Mic meter:', e);
}
}
function stopMicMeter() {
if (micAnimId) cancelAnimationFrame(micAnimId);
micAnimId = null;
micAnalyser = null;
document.getElementById('micBar').style.display = 'none';
}
// ---------- TTS ----------
function playTTS(text) {
return new Promise((resolve) => {
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'it-IT';
utter.rate = 0.9;
const voices = speechSynthesis.getVoices();
const it = voices.find(v => v.lang.startsWith('it'));
if (it) utter.voice = it;
utter.onend = resolve;
utter.onerror = resolve;
speechSynthesis.cancel();
speechSynthesis.speak(utter);
});
}
// ---------- Запись ----------
async function startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Индикатор запускаем ПОСЛЕ получения stream
startMicMeter(stream);
audioChunks = [];
// Проверяем поддержку кодеков
let mimeType = 'audio/webm;codecs=opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'audio/webm';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = ''; // default
}
}
const opts = mimeType ? { mimeType } : {};
mediaRecorder = new MediaRecorder(stream, opts);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => {
stopMicMeter();
recordDuration = (Date.now() - recordStartTime) / 1000;
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
stream.getTracks().forEach(t => t.stop());
enable('btnCompare', true);
enable('btnStereo', true);
enable('btnPlayUser', true);
const btn = document.getElementById('btnRecord');
btn.textContent = '🎙 Записать';
btn.disabled = false;
isRecording = false;
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
};
mediaRecorder.start(100); // собираем чанки каждые 100ms
recordStartTime = Date.now();
setStatus('Идёт запись...');
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
}
// ---------- Кнопка Запись ----------
document.getElementById('btnRecord').onclick = async function () {
const btn = this;
if (isRecording) {
btn.disabled = true;
stopRecording();
return;
}
try {
isRecording = true;
btn.textContent = '■ Стоп';
await startRecording();
} catch (err) {
let msg = '⛔ ';
if (err.name === 'NotAllowedError') msg += 'Доступ к микрофону запрещён. Нажмите на замочек 🔒 в адресной строке → Микрофон → Разрешить.';
else if (err.name === 'NotFoundError') msg += 'Микрофон не найден. Подключите микрофон.';
else if (err.name === 'NotReadableError') msg += 'Микрофон занят другим приложением.';
else msg += 'Ошибка: ' + (err.message || err.name);
setStatus(msg);
btn.textContent = '🎙 Записать';
isRecording = false;
}
};
// ---------- Воспроизведение своей записи ----------
document.getElementById('btnPlayUser').onclick = async () => {
if (!userAudioBlob) return setStatus('Нет записи!');
if (userAudioBlob.size === 0) return setStatus('Запись пустая!');
const url = URL.createObjectURL(userAudioBlob);
const audio = new Audio(url);
audio.volume = 1.0;
audio.onended = () => { URL.revokeObjectURL(url); setStatus('Готово.'); };
audio.onerror = () => { URL.revokeObjectURL(url); setStatus('Ошибка воспроизведения.'); };
setStatus('🔊 Ваша запись...');
try {
await audio.play();
} catch (err) {
URL.revokeObjectURL(url);
setStatus('Ошибка воспроизведения — нажмите ещё раз.');
}
};
async function transcribe(blob) {
if (!GROQ_API_KEY) { setStatus('⛔ Нет API ключа Groq!'); return ''; }
setStatus('Отправка на Whisper...');
const formData = new FormData();
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
formData.append('model', 'whisper-large-v3');
formData.append('language', 'it');
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
body: formData,
signal: controller.signal
});
clearTimeout(timer);
const data = await res.json();
if (data.error) {
setStatus('⛔ Whisper: ' + (data.error.message || 'Forbidden — нужен VPN или новый ключ'));
return '';
}
return data.text || '';
} catch (err) {
if (err.name === 'AbortError') setStatus('⛔ Whisper: таймаут (30с). Проверьте VPN/интернет.');
else setStatus('⛔ Whisper: ' + (err.message || 'сетевая ошибка. Нужен VPN?'));
return '';
}
}
function normalize(text) {
return text.toLowerCase().replace(/[.,!?;:«»"']/g, '').trim();
}
// ---------- Нечёткое сравнение (Левенштейн) ----------
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({length: m+1}, (_,i) => [i]);
for (let j=0; j<=n; j++) dp[0][j] = j;
for (let i=1; i<=m; i++)
for (let j=1; j<=n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[m][n];
}
function wordSimilarity(a, b) {
// 0..1 где 1 = идентично
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
const dist = levenshtein(a, b);
return 1 - dist / maxLen;
}
function compareTexts(original, transcribed) {
const a = normalize(original).split(/\s+/);
const b = normalize(transcribed).split(/\s+/);
let totalScore = 0;
const result = a.map(word => {
let best = 0, bestMatch = '';
for (const t of b) {
const sim = wordSimilarity(word, t);
if (sim > best) { best = sim; bestMatch = t; }
}
totalScore += best;
// ok=зелёный (≥80%), near=жёлтый (≥50%), bad=красный
const quality = best >= 0.8 ? 'ok' : best >= 0.5 ? 'near' : 'bad';
return { word, quality, bestMatch, score: Math.round(best*100) };
});
const score = Math.round((totalScore / a.length) * 100);
return { score, words: result };
}
function renderDiff(words) {
return words.map(({ word, quality, bestMatch, score }) => {
const color = quality === 'ok' ? 'green' : quality === 'near' ? '#e6a800' : 'red';
const tip = quality !== 'ok' ? `${bestMatch} (${score}%)` : '';
return `<span style="color:${color}; font-weight:bold">${word}</span><span style="color:#888; font-size:0.85em">${tip}</span>`;
}).join(' ');
}
async function playStereo(ttsText, userBlob) {
// Простой вариант: параллельно TTS и user audio, pan через Web Audio API
const ctx = new AudioContext();
// User audio
const userURL = URL.createObjectURL(userBlob);
const userAudio = new Audio(userURL);
const userSource = ctx.createMediaElementSource(userAudio);
const userPan = ctx.createStereoPanner();
userPan.pan.value = 1;
userSource.connect(userPan).connect(ctx.destination);
// TTS
await playTTS(ttsText);
userAudio.play();
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
}
document.getElementById('btnTTS').onclick = async () => {
ttsText = document.getElementById('inputText').value.trim();
if (!ttsText) return setStatus('Введите текст!');
saveToHistory(ttsText);
translateToRussian(ttsText);
setStatus('🔊 Эталон...');
await playTTS(ttsText);
setStatus('Готово.');
};
document.getElementById('btnCompare').onclick = async () => {
if (!userAudioBlob) return setStatus('Нет записи!');
const original = document.getElementById('inputText').value.trim();
setStatus('Анализ...');
const [transcribed, phon] = await Promise.all([
transcribe(userAudioBlob),
phoneticAnalysis(userAudioBlob)
]);
if (!transcribed) return setStatus('Whisper не распознал речь.');
const { score, words } = compareTexts(original, transcribed);
const recEmoji = score >= 90 ? '🟢' : score >= 70 ? '🟡' : '🔴';
const phonEmoji = phon.phonScore >= 85 ? '🟢' : phon.phonScore >= 60 ? '🟡' : '🔴';
document.getElementById('score').innerHTML =
recEmoji + ' Распознавание: <b>' + score + '%</b>' +
' &nbsp;|&nbsp; ' + phonEmoji + ' Произношение: <b>' + phon.phonScore + '%</b>';
document.getElementById('diff').innerHTML =
'<div style="margin-bottom:6px;color:#666;font-size:0.9em">' +
'🗣 Whisper: <b>' + transcribed + '</b> &nbsp;·&nbsp; ⏱ ' + recordDuration.toFixed(1) + 'с' +
'</div>' +
'<div class="phonetics">' +
'<span>🎵 Тон: ' + phon.pitchHz + ' Hz</span>' +
'<span>📏 Стабильность: ' + phon.pitchStability + '%</span>' +
'<span>🔊 Чистота: ' + phon.voiceClarity + '%</span>' +
'</div>' +
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;50%' +
'</div>' +
renderDiff(words);
setStatus('Готово.');
};
document.getElementById('btnStereo').onclick = async () => {
const text = ttsText || document.getElementById('inputText').value.trim();
if (!text) return setStatus('Введите текст для эталона!');
if (!userAudioBlob) return setStatus('Нет записи! Нажмите 🎙 Записать.');
await playStereo(text, userAudioBlob);
};
</script>
</body>
</html>