Files
lang/index.html
T

557 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>