Files
lang/dist/index.html
T

770 lines
29 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
/* 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. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (произнесите) &nbsp;&nbsp; 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> &nbsp;|  🗣 <i>' + whisperResult.text + '</i> &nbsp;|  ';
diffHTML =
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;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>