1142 lines
46 KiB
HTML
1142 lines
46 KiB
HTML
<!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. Введите фразу → 2. ▶ Эталон (прослушайте) → 3. 🎙 Записать (сделайте короткую паузу перед произнесением) → 4. 📊 Сравнить
|
||
</div>
|
||
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
|
||
<div id="history"></div>
|
||
<div style="margin:12px 0; display:flex; gap:8px; flex-wrap:wrap">
|
||
<button id="btnTTS">▶ Эталон</button>
|
||
<button id="btnRecord">🎙 Записать</button>
|
||
<button id="btnPlayUser" disabled>🔊 Ваш голос</button>
|
||
<button id="btnCompare" disabled>📊 Сравнить</button>
|
||
<button id="btnStereo" disabled>🎧 Стерео</button>
|
||
</div>
|
||
<div id="status" style="color:gray; font-size:0.9em"></div>
|
||
<div id="micBar" style="height:12px; background:#e0e0e0; border-radius:6px; margin:8px 0; overflow:hidden; display:none">
|
||
<div id="micFill" style="height:100%; width:0%; background:#4caf50; border-radius:6px; transition:width 0.08s"></div>
|
||
</div>
|
||
<div id="score"></div>
|
||
<div id="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> | 🗣 <i>' + whisperResult.text + '</i>';
|
||
diffHTML =
|
||
'<div style="margin:6px 0;font-size:0.8em;color:#999">' +
|
||
'📋 Текст: зелёный ≥80%, жёлтый ≥50%, красный <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> |