v64: рефакторинг — разбил монолит на css/ js/ build.sh dist/ (781→43 строки index.html)
This commit is contained in:
+252
@@ -0,0 +1,252 @@
|
||||
// ---------- Аудио: запись, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user