v73: обрезка тишины (начало/конец) → WAV

This commit is contained in:
“Naeel”
2026-05-22 21:27:46 +04:00
parent 6ab7a26bbc
commit f2762b4f0a
4 changed files with 152 additions and 8 deletions
+76 -4
View File
@@ -257,7 +257,7 @@ async function transcribe(blob) {
const tStart = performance.now(); const tStart = performance.now();
const form = new FormData(); const form = new FormData();
form.append('file', blob, 'audio.webm'); form.append('file', blob, blob.type === 'audio/wav' ? 'audio.wav' : 'audio.webm');
form.append('model', 'whisper-large-v3'); form.append('model', 'whisper-large-v3');
form.append('language', 'it'); form.append('language', 'it');
@@ -365,11 +365,15 @@ async function startRecording() {
const opts = mimeType ? { mimeType } : {}; const opts = mimeType ? { mimeType } : {};
mediaRecorder = new MediaRecorder(stream, opts); mediaRecorder = new MediaRecorder(stream, opts);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => { mediaRecorder.onstop = async () => {
stopMicMeter(); stopMicMeter();
recordDuration = (Date.now() - recordStartTime) / 1000; recordDuration = (Date.now() - recordStartTime) / 1000;
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' }); let blob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
stream.getTracks().forEach(t => t.stop()); stream.getTracks().forEach(t => t.stop());
// Обрезаем тишину
const trimmed = await trimSilence(blob);
if (trimmed && trimmed.size > 0) blob = trimmed;
userAudioBlob = blob;
enable('btnCompare', true); enable('btnCompare', true);
enable('btnStereo', true); enable('btnStereo', true);
enable('btnPlayUser', true); enable('btnPlayUser', true);
@@ -554,6 +558,74 @@ function pitchFrame(data, start, len, sr) {
return bestR > 0.30 ? sr / bestLag : 0; return bestR > 0.30 ? sr / bestLag : 0;
} }
// ---------- Обрезка тишины (щелчок в начале, тишина в конце) ----------
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;
// RMS по окнам 10мс
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 blob; // тишина
const threshold = maxRms * 0.02; // 2% от пика
// Начало — первый кадр выше порога
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 blob; // слишком коротко
// WAV: 44-byte header + PCM16
const trimmed = data.slice(startSample, endSample);
const numSamples = trimmed.length;
const wav = new ArrayBuffer(44 + numSamples * 2);
const view = new DataView(wav);
const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + numSamples * 2, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sr, true);
view.setUint32(28, sr * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, numSamples * 2, true);
for (let i = 0; i < numSamples; i++) {
const s = Math.max(-1, Math.min(1, trimmed[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([wav], { type: 'audio/wav' });
} catch(e) { return blob; }
}
</script> </script>
<script> <script>
// ---------- Сравнение текстов (Левенштейн) и doCompare ---------- // ---------- Сравнение текстов (Левенштейн) и doCompare ----------
@@ -694,7 +766,7 @@ async function doCompare(blob, originalText, duration) {
<script> <script>
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = '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'; const GROQ_API_KEY = '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';
const VERSION = 'v72'; const VERSION = 'v73';
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
+74 -2
View File
@@ -62,11 +62,15 @@ async function startRecording() {
const opts = mimeType ? { mimeType } : {}; const opts = mimeType ? { mimeType } : {};
mediaRecorder = new MediaRecorder(stream, opts); mediaRecorder = new MediaRecorder(stream, opts);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.onstop = () => { mediaRecorder.onstop = async () => {
stopMicMeter(); stopMicMeter();
recordDuration = (Date.now() - recordStartTime) / 1000; recordDuration = (Date.now() - recordStartTime) / 1000;
userAudioBlob = new Blob(audioChunks, { type: mimeType || 'audio/webm' }); let blob = new Blob(audioChunks, { type: mimeType || 'audio/webm' });
stream.getTracks().forEach(t => t.stop()); stream.getTracks().forEach(t => t.stop());
// Обрезаем тишину
const trimmed = await trimSilence(blob);
if (trimmed && trimmed.size > 0) blob = trimmed;
userAudioBlob = blob;
enable('btnCompare', true); enable('btnCompare', true);
enable('btnStereo', true); enable('btnStereo', true);
enable('btnPlayUser', true); enable('btnPlayUser', true);
@@ -250,3 +254,71 @@ function pitchFrame(data, start, len, sr) {
} }
return bestR > 0.30 ? sr / bestLag : 0; return bestR > 0.30 ? sr / bestLag : 0;
} }
// ---------- Обрезка тишины (щелчок в начале, тишина в конце) ----------
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;
// RMS по окнам 10мс
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 blob; // тишина
const threshold = maxRms * 0.02; // 2% от пика
// Начало — первый кадр выше порога
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 blob; // слишком коротко
// WAV: 44-byte header + PCM16
const trimmed = data.slice(startSample, endSample);
const numSamples = trimmed.length;
const wav = new ArrayBuffer(44 + numSamples * 2);
const view = new DataView(wav);
const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
writeStr(0, 'RIFF');
view.setUint32(4, 36 + numSamples * 2, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sr, true);
view.setUint32(28, sr * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, numSamples * 2, true);
for (let i = 0; i < numSamples; i++) {
const s = Math.max(-1, Math.min(1, trimmed[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([wav], { type: 'audio/wav' });
} catch(e) { return blob; }
}
+1 -1
View File
@@ -1,6 +1,6 @@
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = ''; const GROQ_API_KEY = '';
const VERSION = 'v72'; const VERSION = 'v73';
let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = ''; let mediaRecorder, audioChunks = [], userAudioBlob = null, ttsText = '';
let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null; let isRecording = false, isAnalyzing = false, micAnalyser = null, micAnimId = null;
+1 -1
View File
@@ -5,7 +5,7 @@ async function transcribe(blob) {
const tStart = performance.now(); const tStart = performance.now();
const form = new FormData(); const form = new FormData();
form.append('file', blob, 'audio.webm'); form.append('file', blob, blob.type === 'audio/wav' ? 'audio.wav' : 'audio.webm');
form.append('model', 'whisper-large-v3'); form.append('model', 'whisper-large-v3');
form.append('language', 'it'); form.append('language', 'it');