Files
lang/dist/index.html

1142 lines
46 KiB
HTML
Raw Permalink 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; user-select:none; }
.syl-chip:hover { background:#e0e0ff; }
.syl-chip:active { background:#b0b0ff; }
</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="pronounce"></div>
<div id="diff"></div>
<div id="syllables"></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'});
let trHTML = '';
if (r.transcription) {
trHTML = '<span style="color:#aaa;font-size:0.82em;margin:0 4px">' +
r.transcription.split(/\s+/).map(w => {
const sy = syllabifyIT(w);
if (sy.includes('-')) {
return sy.split('-').map((s,si) =>
'<span style="cursor:pointer;padding:0 2px;border-radius:3px"' +
' onmouseover="this.style.background=\'#ffeb3b\'"' +
' onmouseout="this.style.background=\'\'"' +
' onclick="playHistorySyl(' + r.id + ',' + si + ',' + sy.split('-').length + ')">' + s + '</span>'
).join('<span style="color:#ccc">-</span>');
}
return w;
}).join(' ') +
'</span>';
}
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>' +
trHTML +
'<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 syllabifyIntoArray(text) {
const result = [];
for (const w of text.split(/\s+/)) {
for (const s of syllabifyIT(w).split('-')) result.push(s);
}
return result;
}
function syllabifyPhrase(text) {
return text.split(/\s+/).map(syllabifyIT).join(' ');
}
// Сопоставление слогов с временными метками Whisper
// whisperWords: [{word, start, end}, ...]
// originalText: исходный текст (что пользователь должен был сказать)
// Возвращает: [{syl, start, end}, ...] для каждого слога
// Принцип: берём общий диапазон речи [первое_слово.start, последнее_слово.end]
// и пропорционально делим его по длине слогов исходного текста
function mapSyllablesToTimestamps(originalText, whisperWords) {
const syllables = syllabifyIntoArray(originalText);
if (!whisperWords || whisperWords.length === 0) {
return syllables.map(s => ({ syl: s, start: null, end: null }));
}
// Диапазон речи (от первого до последнего слова)
const speechStart = whisperWords[0].start;
const speechEnd = whisperWords[whisperWords.length - 1].end;
const speechDur = speechEnd - speechStart;
if (speechDur <= 0) {
return syllables.map(s => ({ syl: s, start: speechStart, end: speechStart + 0.1 }));
}
// Суммарная длина всех слогов в символах
const totalChars = syllables.reduce((sum, s) => sum + s.length, 0);
// Распределяем временной диапазон пропорционально длине слогов
let charOffset = 0;
return syllables.map(syl => {
const sylRatio = syl.length / totalChars;
const start = speechStart + (charOffset / totalChars) * speechDur;
const end = start + sylRatio * speechDur;
charOffset += syl.length;
return { syl, start, end };
});
}
// ---------- AudioContext для проигрывания слогов ----------
let _audioCtx = null;
async function getAudioCtx() {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') await _audioCtx.resume();
return _audioCtx;
}
let _currentBlob = null;
let _currentSylBuf = null;
let _currentSylMap = null; // результат mapSyllablesToTimestamps
let _speechOnset = 0; // реальное начало речи по амплитуде (сек)
let _speechEnd = 0; // реальный конец речи по амплитуде (сек)
// Поиск реального начала/конца речи по амплитуде аудиобуфера
function findSpeechRange(buf) {
const data = buf.getChannelData(0);
const sr = buf.sampleRate;
// Порог: 3% от максимальной амплитуды
let maxAmp = 0;
for (let i = 0; i < data.length; i++) maxAmp = Math.max(maxAmp, Math.abs(data[i]));
const threshold = maxAmp * 0.03;
let onset = 0, ending = buf.duration;
for (let i = 0; i < data.length; i++) {
if (Math.abs(data[i]) > threshold) { onset = i / sr; break; }
}
for (let i = data.length - 1; i >= 0; i--) {
if (Math.abs(data[i]) > threshold) { ending = i / sr; break; }
}
// Минимальная длительность речи
if (ending - onset < 0.1) { onset = 0; ending = buf.duration; }
return { onset, ending };
}
async function playSyllable(sylIdx, total) {
if (!_currentBlob) return;
try {
const ctx = await getAudioCtx();
if (!_currentSylBuf) {
const ab = await _currentBlob.arrayBuffer();
_currentSylBuf = await ctx.decodeAudioData(ab);
const range = findSpeechRange(_currentSylBuf);
_speechOnset = range.onset;
_speechEnd = range.ending;
console.log('[SYL] speechRange: ' + _speechOnset.toFixed(3) + '' + _speechEnd.toFixed(3) + 's (total ' + _currentSylBuf.duration.toFixed(3) + 's)');
}
let start, len;
// Используем точные таймстемпы Whisper + коррекция по амплитуде
if (_currentSylMap && sylIdx < _currentSylMap.length) {
const sm = _currentSylMap[sylIdx];
if (sm.start !== null && sm.end !== null) {
// Сдвигаем whisper-таймстемпы на реальное начало речи
const shift = _speechOnset - (_currentSylMap[0].start || 0);
start = sm.start + shift;
len = sm.end - sm.start;
// Минимальная длительность 80ms чтобы слог был отчётливо слышен
if (len < 0.08) { const mid = start + len/2; start = mid - 0.04; len = 0.08; }
// Не выходить за границы буфера
if (start < 0) start = 0;
if (start + len > _currentSylBuf.duration) len = _currentSylBuf.duration - start;
} else {
// Fallback: равномерное деление
const dur = _currentSylBuf.duration;
const firstWordStart = _currentSylMap.find(s => s.start !== null)?.start || 0;
const effectiveDur = dur - firstWordStart;
start = firstWordStart + (effectiveDur / total) * sylIdx;
len = effectiveDur / total;
}
} else {
// Без Whisper-данных: равномерное деление всей дорожки
const dur = _currentSylBuf.duration;
start = (dur / total) * sylIdx;
len = dur / total;
}
console.log('[SYL] #' + sylIdx + '/' + total + ' start=' + start.toFixed(3) + ' len=' + len.toFixed(3));
const src = ctx.createBufferSource();
src.buffer = _currentSylBuf;
src.connect(ctx.destination);
src.start(0, start, len);
} catch(e) { console.log('[SYL] ERR', e); }
}
// Проигрывание слога из истории (загрузка blob из IndexedDB)
async function playHistorySyl(recId, sylIdx, total) {
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(recId); req.onsuccess = () => r(req.result); });
if (!rec?.blob) return;
_currentBlob = rec.blob;
_currentSylBuf = null;
_currentSylMap = null;
await playSyllable(sylIdx, total);
} catch(e) {}
}
</script>
<script>
// ---------- Groq API: транскрипция (прямой POST через ProxyAPI.ru) ----------
async function blobToWav(blob) {
const ab = await blob.arrayBuffer();
const ctx = new AudioContext({ sampleRate: 16000 });
const buf = await ctx.decodeAudioData(ab);
await ctx.close();
const mono = new Float32Array(buf.length);
for (let c = 0; c < buf.numberOfChannels; c++) {
const ch = buf.getChannelData(c);
for (let i = 0; i < buf.length; i++) mono[i] += ch[i];
}
if (buf.numberOfChannels > 1) for (let i = 0; i < mono.length; i++) mono[i] /= buf.numberOfChannels;
const wavBuf = new ArrayBuffer(44 + mono.length * 2);
const v = new DataView(wavBuf);
const wr = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
wr(0, 'RIFF'); v.setUint32(4, 36 + mono.length * 2, true);
wr(8, 'WAVE'); wr(12, 'fmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, 16000, true); v.setUint32(28, 32000, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
wr(36, 'data'); v.setUint32(40, mono.length * 2, true);
let off = 44;
for (let i = 0; i < mono.length; i++) {
const s = Math.max(-1, Math.min(1, mono[i]));
v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); off += 2;
}
return new Blob([wavBuf], { type: 'audio/wav' });
}
async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', words: [], error: 'no_key' };
const tStart = performance.now();
try {
const wavBlob = await blobToWav(blob);
const fd = new FormData();
fd.append('file', wavBlob, 'audio.wav');
fd.append('model', 'whisper-1');
fd.append('language', 'it');
fd.append('response_format', 'verbose_json');
fd.append('timestamp_granularities[]', 'word');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000);
const r = await fetch('https://api.proxyapi.ru/openai/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}` },
body: fd,
signal: ctrl.signal
});
clearTimeout(timer);
const raw = await r.text();
console.log('[TRANSCRIBE] ← ' + r.status + ' ' + ((performance.now()-tStart)/1000).toFixed(2) + 's raw=' + raw.slice(0,200));
let data;
try { data = JSON.parse(raw); } catch(pe) { return { text: '', words: [], error: 'parse:' + raw.slice(0,80) }; }
const words = (data.words || []).map(w => ({ word: w.word, start: w.start, end: w.end }));
console.log('[TRANSCRIBE] words:', words.length, words.map(w => w.start.toFixed(2)+'-'+w.end.toFixed(2)+' '+w.word).join(', '));
return { text: data.text || '', words, error: data.error?.message || data.error || null };
} catch(e) {
console.log('[TRANSCRIBE] ERR', e);
return { text: '', words: [], error: e.message };
}
}
async function translateToRussian(text) {
if (!GROQ_API_KEY) return;
try {
const res = await fetch('https://lang.kube5s.ru/openai/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o',
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>
// ---------- Pronunciation Assessment ----------
async function assessPronunciation(expectedText, whisperResult) {
try {
const r = await fetch('/pronounce/assess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expected: expectedText,
whisper: whisperResult,
lang: 'it'
})
});
if (!r.ok) { console.log('[PRONOUNCE] HTTP', r.status); return null; }
const data = await r.json();
console.log('[PRONOUNCE] score=' + data.overall_score + ' ' + data.quality);
return data;
} catch(e) {
console.log('[PRONOUNCE] ERR', e);
return null;
}
}
function renderPronunciation(assess) {
if (!assess) return '';
const s = assess.overall_score;
const emoji = s >= 90 ? '🟢' : s >= 70 ? '🟡' : s >= 50 ? '🟠' : '🔴';
let html = '<div style="margin:10px 0;padding:12px;background:#1a1a2e;border-radius:8px;color:#e0e0e0">';
// Score bar
const barColor = s >= 90 ? '#4caf50' : s >= 70 ? '#ff9800' : s >= 50 ? '#f44336' : '#9e9e9e';
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">';
html += '<span style="font-size:2em">' + emoji + '</span>';
html += '<div style="flex:1">';
html += '<div style="font-size:1.4em;font-weight:bold">Произношение: <span style="color:' + barColor + '">' + s + '%</span></div>';
html += '<div style="color:#aaa;font-size:0.9em">' + (assess.quality || '') + '</div>';
// Bar
html += '<div style="height:6px;background:#333;border-radius:3px;margin-top:6px">';
html += '<div style="width:' + s + '%;height:100%;background:' + barColor + ';border-radius:3px;transition:width 0.5s"></div></div>';
html += '</div></div>';
// Details grid
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:0.85em">';
// Phonemes
if (assess.phoneme_comparison) {
const pc = assess.phoneme_comparison;
html += '<div><b>🔤 Фонемы:</b> ' + pc.accuracy + '%</div>';
html += '<div><b>✓ Совпадений:</b> ' + (pc.matches || 0) + '/' + (pc.total_phonemes || 0) + '</div>';
if (pc.errors && pc.errors.length > 0) {
const subs = pc.errors.filter(e => e.type === 'sub');
const dels = pc.errors.filter(e => e.type === 'del');
const inss = pc.errors.filter(e => e.type === 'ins');
html += '<div style="grid-column:1/-1">';
if (subs.length) html += '<span style="color:#ff9800">Замен: ' + subs.map(e => e.expected + '→' + e.actual).join(', ') + '</span> ';
if (dels.length) html += '<span style="color:#f44336">Пропущено: ' + dels.map(e => e.expected).join(', ') + '</span> ';
if (inss.length) html += '<span style="color:#9c27b0">Лишних: ' + inss.map(e => e.actual).join(', ') + '</span>';
html += '</div>';
}
}
// Timing
if (assess.timing) {
const tm = assess.timing;
html += '<div><b>⏱ Ритм:</b> ' + (tm.rhythm_score || 0) + '%</div>';
html += '<div><b>📏 Длит-ть:</b> ' + (tm.total_duration || 0).toFixed(1) + 'с</div>';
if (tm.timing_quality) {
const tq = tm.timing_quality;
html += '<div style="grid-column:1/-1;color:' + (tq === 'good' ? '#4caf50' : tq === 'ok' ? '#ff9800' : '#f44336') + '">';
html += tq === 'good' ? '✅ Ритм ровный' : tq === 'ok' ? '⚠️ Ритм неровный' : '❌ Ритм сбит';
html += '</div>';
}
}
html += '</div>';
// Feedback
if (assess.feedback) {
html += '<div style="margin-top:8px;padding:6px 10px;background:#2a2a3e;border-radius:4px;font-size:0.9em;color:#ccc">';
html += '💬 ' + assess.feedback;
html += '</div>';
}
// Phoneme detail
if (assess.expected && assess.expected.phonemes) {
html += '<div style="margin-top:6px;font-size:0.75em;color:#666">';
html += '🎯 Эталон: /' + assess.expected.phonemes.join(' ') + '/';
if (assess.actual && assess.actual.phonemes) {
html += ' | 🗣 Вы: /' + assess.actual.phonemes.join(' ') + '/';
}
html += '</div>';
}
html += '</div>';
return html;
}
</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();
}
// ---------- Воспроизведение (с обрезкой 0.2с в начале) ----------
async function playTrimmed(blob) {
const ctx = await getAudioCtx();
const ab = await blob.arrayBuffer();
const rawBuf = await ctx.decodeAudioData(ab);
const sr = rawBuf.sampleRate;
const data = rawBuf.getChannelData(0);
const cutSamples = Math.floor(sr * 0.2);
const trimmed = data.slice(cutSamples);
const buf = ctx.createBuffer(1, trimmed.length, sr);
buf.getChannelData(0).set(trimmed);
const src = ctx.createBufferSource();
src.buffer = buf;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01);
src.connect(gain).connect(ctx.destination);
src.start();
return new Promise(resolve => { src.onended = resolve; });
}
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) await playTrimmed(rec.blob);
} 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;
}
// ---------- Обрезка тишины — возвращает {buffer, startSample, endSample} ----------
async function trimSilence(blob) {
try {
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
const ab = await blob.arrayBuffer();
const buf = await ctx.decodeAudioData(ab);
const data = buf.getChannelData(0);
const sr = buf.sampleRate;
const len = data.length;
const win = Math.floor(sr * 0.01);
const rms = [];
let maxRms = 0;
for (let i = 0; i < len; i += win) {
let sum = 0, n = 0;
for (let j = i; j < i + win && j < len; j++, n++) sum += data[j] * data[j];
const v = Math.sqrt(sum / n);
rms.push(v);
if (v > maxRms) maxRms = v;
}
if (maxRms < 0.001) return null;
const threshold = maxRms * 0.02;
let startIdx = 0;
for (let i = 0; i < rms.length; i++) {
if (rms[i] > threshold) { startIdx = Math.max(0, i - 2); break; }
}
let endIdx = rms.length - 1;
for (let i = rms.length - 1; i >= 0; i--) {
if (rms[i] > threshold) { endIdx = Math.min(rms.length - 1, i + 2); break; }
}
const startSample = startIdx * win;
const endSample = Math.min((endIdx + 1) * win, len);
if (endSample - startSample < sr * 0.08) return null;
const trimmedBuf = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, endSample - startSample, sr);
return { buffer: trimmedBuf, data: data.slice(startSample, endSample), sr, startSample, endSample };
} catch(e) { return null; }
}
// ---------- WAV кодирование (для отправки в Whisper) ----------
function encodeWAV(samples, sampleRate) {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const w = (o, s) => { for (let i=0;i<s.length;i++) v.setUint8(o+i, s.charCodeAt(i)); };
w(0, 'RIFF'); v.setUint32(4, 36+samples.length*2, true); w(8, 'WAVE');
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate*2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
w(36, 'data'); v.setUint32(40, samples.length*2, true);
for (let i=0;i<samples.length;i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44+i*2, s<0?s*0x8000:s*0x7FFF, true);
}
return buf;
}
// Обрезает первые cutSec секунд, возвращает WAV Blob (для Groq)
async function trimStartBlob(blob, cutSec = 0.2) {
try {
const ab = await blob.arrayBuffer();
const actx = new AudioContext();
const rawBuf = await actx.decodeAudioData(ab);
const sr = rawBuf.sampleRate;
const cutSamples = Math.floor(sr * cutSec);
const newLen = rawBuf.length - cutSamples;
if (newLen < sr * 0.05) { actx.close(); return blob; }
// Render trimmed via OfflineAudioContext (надёжнее ручного slice)
const offline = new OfflineAudioContext(1, newLen, sr);
const src = offline.createBufferSource();
src.buffer = rawBuf;
src.connect(offline.destination);
src.start(0, cutSec / sr);
const rendered = await offline.startRendering();
actx.close();
const wav = encodeWAV(rendered.getChannelData(0), sr);
return new Blob([wav], { type: 'audio/wav' });
} catch(e) {
console.log('[trimStartBlob] fallback to raw:', e);
return blob;
}
}
</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;
try {
whisperResult = await transcribe(blob);
} catch(e) {
clearInterval(timerId);
isAnalyzing = false;
setStatus('Ошибка анализа.');
return '';
}
clearInterval(timerId);
let recHTML = '', diffHTML = '', pronounceHTML = '';
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>';
diffHTML =
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный &lt;50%' +
'</div>' +
renderDiff(words);
// Запускаем pronunciation assessment (параллельно, не блокируем)
assessPronunciation(originalText, whisperResult).then(assess => {
if (assess) {
const el = document.getElementById('pronounce');
if (el) el.innerHTML = renderPronunciation(assess);
}
});
} else {
recHTML = '⚠️ Не распознано';
diffHTML = '';
}
document.getElementById('score').innerHTML = recHTML;
document.getElementById('pronounce').innerHTML = pronounceHTML;
document.getElementById('diff').innerHTML = diffHTML;
// Слоги — в отдельный div, не в diff (diff перезаписывается)
_currentBlob = blob;
_currentSylBuf = null;
_speechOnset = 0;
_speechEnd = 0;
_currentSylMap = whisperResult.words && whisperResult.words.length > 0
? mapSyllablesToTimestamps(originalText, whisperResult.words)
: null;
console.log('[SYL] map:', _currentSylMap);
const sylDiv = document.getElementById('syllables');
sylDiv.innerHTML = '';
// Показываем отладочную инфу: диапазон речи и первый/последний слог
if (_currentSylMap && _currentSylMap.length > 0) {
const first = _currentSylMap[0], last = _currentSylMap[_currentSylMap.length - 1];
const dbg = document.createElement('div');
dbg.style.cssText = 'font-size:0.7em;color:#aaa;margin-top:2px';
dbg.textContent = '⏱ речь: ' + (first.start||0).toFixed(2) + '' + (last.end||0).toFixed(2) + 'с | ' +
_currentSylMap.map(s => s.syl + '[' + (s.start||0).toFixed(2) + ']').join(' ');
sylDiv.appendChild(dbg);
}
const sy = syllabifyIT(originalText);
if (sy && sy.includes('-')) {
const syls = sy.split('-');
const div = document.createElement('div');
div.style.cssText = 'margin-top:8px;font-size:1.2em;letter-spacing:2px;user-select:none';
syls.forEach((s, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.style.cssText = 'color:#ccc;margin:0 1px';
sep.textContent = '-';
div.appendChild(sep);
}
const chip = document.createElement('span');
chip.textContent = s;
chip.style.cssText = 'cursor:pointer;padding:2px 6px;border-radius:4px;transition:background 0.1s';
chip.onmouseover = () => { chip.style.background = '#d0d0ff'; };
chip.onmouseout = () => { chip.style.background = ''; };
chip.onclick = () => { chip.style.background = '#a0a0ff'; setTimeout(() => { chip.style.background = '#d0d0ff'; }, 200); playSyllable(i, syls.length); };
div.appendChild(chip);
});
sylDiv.appendChild(div);
}
setStatus('Готово.');
isAnalyzing = false;
return whisperResult.text || '';
}
</script>
<script>
// ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = 'sk-gSreH37bQ34Dh9cNO7sfP6goZABnXgZL';
const VERSION = 'v107';
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('Нет записи!');
setStatus('🔊 Ваша запись...');
try {
await playTrimmed(userAudioBlob);
setStatus('Готово.');
} catch (err) {
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);
};
// ---------- История: 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);
}
} catch(e) { setStatus('Ошибка загрузки записи.'); }
}
</script>
</body>
</html>