295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
// ---------- Аудио: запись, 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; }
|
||
}
|