// ---------- Аудио: запись, 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