Files
lang/js/audio.js
T

295 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Аудио: запись, TTS, фонетический анализ ----------
// ---------- Индикатор микрофона ----------
function startMicMeter(stream) {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
micAnalyser = ctx.createAnalyser();
micAnalyser.fftSize = 256;
src.connect(micAnalyser);
const bar = document.getElementById('micBar');
const fill = document.getElementById('micFill');
bar.style.display = 'block';
fill.style.width = '0%';
const data = new Uint8Array(micAnalyser.frequencyBinCount);
function tick() {
if (!micAnalyser) return;
micAnalyser.getByteFrequencyData(data);
const max = Math.max(...data);
const pct = Math.min(100, Math.round(max / 255 * 100));
fill.style.width = pct + '%';
fill.style.background = pct > 70 ? '#ff5722' : pct > 30 ? '#ff9800' : '#4caf50';
micAnimId = requestAnimationFrame(tick);
}
tick();
} catch(e) { console.warn('Mic meter:', e); }
}
function stopMicMeter() {
if (micAnimId) cancelAnimationFrame(micAnimId);
micAnimId = null;
micAnalyser = null;
document.getElementById('micBar').style.display = 'none';
}
// ---------- TTS ----------
function playTTS(text) {
return new Promise((resolve) => {
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'it-IT';
utter.rate = 0.9;
const voices = speechSynthesis.getVoices();
const it = voices.find(v => v.lang.startsWith('it'));
if (it) utter.voice = it;
utter.onend = resolve;
utter.onerror = resolve;
speechSynthesis.cancel();
speechSynthesis.speak(utter);
});
}
// ---------- Запись ----------
async function startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
startMicMeter(stream);
audioChunks = [];
let mimeType = 'audio/webm;codecs=opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'audio/webm';
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = '';
}
const opts = mimeType ? { mimeType } : {};
mediaRecorder = new MediaRecorder(stream, opts);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => {
stopMicMeter();
recordDuration = (Date.now() - recordStartTime) / 1000;
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
stream.getTracks().forEach(t => t.stop());
enable('btnCompare', true);
enable('btnStereo', true);
enable('btnPlayUser', true);
const btn = document.getElementById('btnRecord');
btn.textContent = '🎙 Записать';
btn.disabled = false;
isRecording = false;
setStatus('Запись завершена. ' + recordDuration.toFixed(1) + 'с · ' + (userAudioBlob.size/1024).toFixed(1) + ' KB');
if (ttsText) saveRecording(userAudioBlob, ttsText, recordDuration);
else {
const t = document.getElementById('inputText').value.trim();
if (t) saveRecording(userAudioBlob, t, recordDuration);
}
};
mediaRecorder.start(100);
recordStartTime = Date.now();
setStatus('Идёт запись...');
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
}
// ---------- Воспроизведение ----------
async function playRec(id) {
try {
const db = await openRecDB();
const tx = db.transaction('recs', 'readonly');
const store = tx.objectStore('recs');
const rec = await new Promise(r => { const req = store.get(id); req.onsuccess = () => r(req.result); });
if (rec?.blob) {
const url = URL.createObjectURL(rec.blob);
const audio = new Audio(url);
await new Promise((resolve, reject) => {
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
audio.onerror = () => { URL.revokeObjectURL(url); reject(); };
audio.play();
});
const tr = await doCompare(rec.blob, rec.word, rec.duration);
if (tr) saveTranscription(id, tr);
}
} catch(e) { /* тихо */ }
}
async function playStereo(ttsText, userBlob) {
const ctx = new AudioContext();
const userURL = URL.createObjectURL(userBlob);
const userAudio = new Audio(userURL);
const userSource = ctx.createMediaElementSource(userAudio);
const userPan = ctx.createStereoPanner();
userPan.pan.value = 1;
userSource.connect(userPan).connect(ctx.destination);
await playTTS(ttsText);
userAudio.play();
setStatus('Стерео-воспроизведение: левое ухо — эталон, правое — ваш голос');
}
// ---------- Фонетический анализ ----------
function phoneticAnalysis(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 44100);
const buf = await ctx.decodeAudioData(e.target.result);
const data = buf.getChannelData(0);
const sr = buf.sampleRate;
const len = data.length;
const frameSize = Math.floor(sr * 0.025);
const step = Math.floor(frameSize / 2);
const numFrames = Math.max(1, Math.floor((len - frameSize) / step));
const rmsArr = new Float32Array(numFrames);
const zcrArr = new Float32Array(numFrames);
for (let fi = 0; fi < numFrames; fi++) {
const s = fi * step;
let rmsSum = 0, zcr = 0;
for (let i = s; i < s + frameSize; i++) {
rmsSum += data[i] * data[i];
if (i > s && (data[i] >= 0) !== (data[i - 1] >= 0)) zcr++;
}
rmsArr[fi] = Math.sqrt(rmsSum / frameSize);
zcrArr[fi] = zcr / frameSize * sr;
}
let meanRms = 0;
for (let fi = 0; fi < numFrames; fi++) meanRms += rmsArr[fi];
meanRms /= numFrames;
const energyThresh = Math.max(0.005, meanRms * 0.4);
const pitches = [];
let voicedCount = 0;
for (let fi = 0; fi < numFrames; fi++) {
if (rmsArr[fi] > energyThresh) {
voicedCount++;
const p = pitchFrame(data, fi * step, frameSize, sr);
if (p > 70 && p < 450) pitches.push(p);
}
}
const voiceRatio = voicedCount / numFrames;
let pitchHz = 0, pitchStability = 0;
if (pitches.length >= 5) {
const sorted = [...pitches].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const mad = pitches.map(p => Math.abs(p - median)).sort((a, b) => a - b)[Math.floor(pitches.length / 2)];
const relMad = (mad / median) * 100;
pitchStability = Math.round(Math.max(0, Math.min(100, 100 - relMad * 2.5)));
pitchHz = Math.round(median);
}
const vzArr = [];
for (let fi = 0; fi < numFrames; fi++) {
if (rmsArr[fi] > energyThresh) vzArr.push(zcrArr[fi]);
}
let articulationScore = 40;
if (vzArr.length > 0) {
const mzc = vzArr.reduce((a, b) => a + b, 0) / vzArr.length;
articulationScore = Math.round(Math.max(0, Math.min(100,
mzc < 150 ? mzc / 1.5 :
mzc < 500 ? 60 + (mzc - 150) / 350 * 20 :
mzc < 1800 ? 80 + (mzc - 500) / 1300 * 18 :
mzc < 3000 ? 98 - (mzc - 1800) / 1200 * 40 :
Math.max(0, 58 - (mzc - 3000) / 1000 * 20)
)));
}
const endStart = Math.floor(numFrames * 0.78);
let endVoiced = 0;
const endTotal = numFrames - endStart;
for (let fi = endStart; fi < numFrames; fi++) {
if (rmsArr[fi] > energyThresh) endVoiced++;
}
const endingScore = endTotal > 0 ? Math.round(Math.min(100, (endVoiced / endTotal) * 125)) : 50;
const clarityScore = Math.round(
voiceRatio < 0.05 ? 0 :
voiceRatio < 0.35 ? voiceRatio / 0.35 * 50 :
voiceRatio < 0.65 ? 50 + (voiceRatio - 0.35) / 0.30 * 45 :
voiceRatio < 0.82 ? 95 - (voiceRatio - 0.65) / 0.17 * 15 :
Math.max(40, 80 - (voiceRatio - 0.82) / 0.18 * 40)
);
const hasSpeech = voiceRatio > 0.08;
const phonScore = hasSpeech ? Math.round(
pitchStability * 0.30 +
articulationScore * 0.25 +
clarityScore * 0.25 +
endingScore * 0.20
) : 0;
resolve({ pitchHz, pitchStability, voiceClarity: articulationScore, phonScore });
} catch (err) {
resolve({ pitchHz: 0, pitchStability: 0, voiceClarity: 0, phonScore: 0 });
}
};
reader.readAsArrayBuffer(blob);
});
}
function pitchFrame(data, start, len, sr) {
const n = len;
let e0 = 0;
for (let i = 0; i < n; i++) e0 += data[start + i] * data[start + i];
if (e0 < 1e-8) return 0;
const minLag = Math.floor(sr / 400);
const maxLag = Math.min(n - 1, Math.floor(sr / 70));
let bestR = 0, bestLag = 0;
for (let lag = minLag; lag <= maxLag; lag++) {
let num = 0, e1 = 0, e2 = 0;
const m = n - lag;
for (let i = 0; i < m; i++) {
const a = data[start + i], b = data[start + i + lag];
num += a * b;
e1 += a * a;
e2 += b * b;
}
const den = Math.sqrt(e1 * e2);
const r = den > 0 ? num / den : 0;
if (r > bestR) { bestR = r; bestLag = lag; }
}
return bestR > 0.30 ? sr / bestLag : 0;
}
// ---------- Обрезка тишины — возвращает {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; }
}