v64: рефакторинг — разбил монолит на css/ js/ build.sh dist/ (781→43 строки index.html)

This commit is contained in:
“Naeel”
2026-05-22 18:39:13 +04:00
parent bd042be61c
commit 9c13c21898
11 changed files with 1516 additions and 747 deletions
+252
View File
@@ -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;
}