v80: обрезка 0.2с в начале записи — убирает щелчок (вместо trimSilence)

This commit is contained in:
“Naeel”
2026-05-23 06:13:46 +04:00
parent 689fa78734
commit f7587a3dcb
5 changed files with 90 additions and 32 deletions
+45 -16
View File
@@ -32,7 +32,7 @@ button:disabled { background:#ccc; cursor:not-allowed; }
<div id="app"> <div id="app">
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1> <h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1>
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap"> <div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (произнесите) &nbsp;&nbsp; 4. 📊 Сравнить 1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (сделайте короткую паузу перед произнесением) &nbsp;&nbsp; 4. 📊 Сравнить
</div> </div>
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea> <textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
<div id="history"></div> <div id="history"></div>
@@ -256,12 +256,15 @@ async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
const tStart = performance.now(); const tStart = performance.now();
// Обрезаем 0.2с в начале (щелчок) — декодируем → WAV
const trimmedBlob = await trimStartBlob(blob);
const form = new FormData(); const form = new FormData();
form.append('file', blob, 'audio.webm'); form.append('file', trimmedBlob, 'audio.wav');
form.append('model', 'whisper-large-v3'); form.append('model', 'whisper-large-v3');
form.append('language', 'it'); form.append('language', 'it');
console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST'); console.log('[HTTP] blob ' + (trimmedBlob.size/1024).toFixed(1) + 'KB → multipart POST');
try { try {
const r = await fetch('https://lang.kube5s.ru/openai/v1/audio/transcriptions', { const r = await fetch('https://lang.kube5s.ru/openai/v1/audio/transcriptions', {
@@ -392,23 +395,20 @@ function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
} }
// ---------- Воспроизведение (с обрезкой тишины) ---------- // ---------- Воспроизведение (с обрезкой 0.2с в начале) ----------
async function playTrimmed(blob) { async function playTrimmed(blob) {
const t = await trimSilence(blob);
const ctx = await getAudioCtx(); const ctx = await getAudioCtx();
let buf; const ab = await blob.arrayBuffer();
if (t) { const rawBuf = await ctx.decodeAudioData(ab);
const offline = new OfflineAudioContext(1, t.data.length, t.sr); const sr = rawBuf.sampleRate;
buf = offline.createBuffer(1, t.data.length, t.sr); const data = rawBuf.getChannelData(0);
buf.getChannelData(0).set(t.data); const cutSamples = Math.floor(sr * 0.2);
} else { const trimmed = data.slice(cutSamples);
const ab = await blob.arrayBuffer(); const buf = ctx.createBuffer(1, trimmed.length, sr);
buf = await ctx.decodeAudioData(ab); buf.getChannelData(0).set(trimmed);
}
const src = ctx.createBufferSource(); const src = ctx.createBufferSource();
src.buffer = buf; src.buffer = buf;
const gain = ctx.createGain(); const gain = ctx.createGain();
// Fade-in 10мс — убирает щелчок при старте
gain.gain.setValueAtTime(0, ctx.currentTime); gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01); gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01);
src.connect(gain).connect(ctx.destination); src.connect(gain).connect(ctx.destination);
@@ -608,6 +608,35 @@ async function trimSilence(blob) {
} catch(e) { return null; } } catch(e) { return null; }
} }
// ---------- WAV кодирование (для отправки в Whisper) ----------
function encodeWAV(samples, sampleRate) {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const w = (o, s) => { for (let i=0;i<s.length;i++) v.setUint8(o+i, s.charCodeAt(i)); };
w(0, 'RIFF'); v.setUint32(4, 36+samples.length*2, true); w(8, 'WAVE');
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate*2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
w(36, 'data'); v.setUint32(40, samples.length*2, true);
for (let i=0;i<samples.length;i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44+i*2, s<0?s*0x8000:s*0x7FFF, true);
}
return buf;
}
// Обрезает первые cutSec секунд, возвращает WAV Blob (для Groq)
async function trimStartBlob(blob, cutSec = 0.2) {
const ctx = new OfflineAudioContext(1, 1, 44100);
const ab = await blob.arrayBuffer();
const buf = await ctx.decodeAudioData(ab);
const sr = buf.sampleRate;
const data = buf.getChannelData(0);
const cutSamples = Math.floor(sr * cutSec);
const trimmed = data.slice(cutSamples);
const wav = encodeWAV(trimmed, sr);
return new Blob([wav], { type: 'audio/wav' });
}
</script> </script>
<script> <script>
// ---------- Сравнение текстов (Левенштейн) и doCompare ---------- // ---------- Сравнение текстов (Левенштейн) и doCompare ----------
@@ -735,7 +764,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 = 'v79'; const VERSION = 'v80';
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
@@ -13,7 +13,7 @@
<div id="app"> <div id="app">
<h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1> <h1>🇮🇹 Lyngvo <span style="font-size:0.5em;color:#aaa" id="ver"></span> <span style="font-size:0.35em;color:#999;font-weight:normal">тренажёр итальянского произношения · для Google Chrome</span></h1>
<div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap"> <div style="font-size:0.8em;color:#666;margin-bottom:14px;white-space:nowrap">
1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (произнесите) &nbsp;&nbsp; 4. 📊 Сравнить 1. Введите фразу &nbsp;&nbsp; 2. ▶ Эталон (прослушайте) &nbsp;&nbsp; 3. 🎙 Записать (сделайте короткую паузу перед произнесением) &nbsp;&nbsp; 4. 📊 Сравнить
</div> </div>
<textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea> <textarea id="inputText" rows="3" placeholder="Введите итальянское слово или фразу..."></textarea>
<div id="history"></div> <div id="history"></div>
+38 -12
View File
@@ -90,23 +90,20 @@ function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
} }
// ---------- Воспроизведение (с обрезкой тишины) ---------- // ---------- Воспроизведение (с обрезкой 0.2с в начале) ----------
async function playTrimmed(blob) { async function playTrimmed(blob) {
const t = await trimSilence(blob);
const ctx = await getAudioCtx(); const ctx = await getAudioCtx();
let buf; const ab = await blob.arrayBuffer();
if (t) { const rawBuf = await ctx.decodeAudioData(ab);
const offline = new OfflineAudioContext(1, t.data.length, t.sr); const sr = rawBuf.sampleRate;
buf = offline.createBuffer(1, t.data.length, t.sr); const data = rawBuf.getChannelData(0);
buf.getChannelData(0).set(t.data); const cutSamples = Math.floor(sr * 0.2);
} else { const trimmed = data.slice(cutSamples);
const ab = await blob.arrayBuffer(); const buf = ctx.createBuffer(1, trimmed.length, sr);
buf = await ctx.decodeAudioData(ab); buf.getChannelData(0).set(trimmed);
}
const src = ctx.createBufferSource(); const src = ctx.createBufferSource();
src.buffer = buf; src.buffer = buf;
const gain = ctx.createGain(); const gain = ctx.createGain();
// Fade-in 10мс — убирает щелчок при старте
gain.gain.setValueAtTime(0, ctx.currentTime); gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01); gain.gain.linearRampToValueAtTime(1, ctx.currentTime + 0.01);
src.connect(gain).connect(ctx.destination); src.connect(gain).connect(ctx.destination);
@@ -305,3 +302,32 @@ async function trimSilence(blob) {
return { buffer: trimmedBuf, data: data.slice(startSample, endSample), sr, startSample, endSample }; return { buffer: trimmedBuf, data: data.slice(startSample, endSample), sr, startSample, endSample };
} catch(e) { return null; } } catch(e) { return null; }
} }
// ---------- WAV кодирование (для отправки в Whisper) ----------
function encodeWAV(samples, sampleRate) {
const buf = new ArrayBuffer(44 + samples.length * 2);
const v = new DataView(buf);
const w = (o, s) => { for (let i=0;i<s.length;i++) v.setUint8(o+i, s.charCodeAt(i)); };
w(0, 'RIFF'); v.setUint32(4, 36+samples.length*2, true); w(8, 'WAVE');
w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate*2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
w(36, 'data'); v.setUint32(40, samples.length*2, true);
for (let i=0;i<samples.length;i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
v.setInt16(44+i*2, s<0?s*0x8000:s*0x7FFF, true);
}
return buf;
}
// Обрезает первые cutSec секунд, возвращает WAV Blob (для Groq)
async function trimStartBlob(blob, cutSec = 0.2) {
const ctx = new OfflineAudioContext(1, 1, 44100);
const ab = await blob.arrayBuffer();
const buf = await ctx.decodeAudioData(ab);
const sr = buf.sampleRate;
const data = buf.getChannelData(0);
const cutSamples = Math.floor(sr * cutSec);
const trimmed = data.slice(cutSamples);
const wav = encodeWAV(trimmed, sr);
return new Blob([wav], { type: 'audio/wav' });
}
+1 -1
View File
@@ -1,6 +1,6 @@
// ---------- Глобальные переменные и инициализация ---------- // ---------- Глобальные переменные и инициализация ----------
const GROQ_API_KEY = ''; const GROQ_API_KEY = '';
const VERSION = 'v79'; const VERSION = 'v80';
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;
+5 -2
View File
@@ -4,12 +4,15 @@ async function transcribe(blob) {
if (!GROQ_API_KEY) return { text: '', error: 'no_key' }; if (!GROQ_API_KEY) return { text: '', error: 'no_key' };
const tStart = performance.now(); const tStart = performance.now();
// Обрезаем 0.2с в начале (щелчок) — декодируем → WAV
const trimmedBlob = await trimStartBlob(blob);
const form = new FormData(); const form = new FormData();
form.append('file', blob, 'audio.webm'); form.append('file', trimmedBlob, 'audio.wav');
form.append('model', 'whisper-large-v3'); form.append('model', 'whisper-large-v3');
form.append('language', 'it'); form.append('language', 'it');
console.log('[HTTP] blob ' + (blob.size/1024).toFixed(1) + 'KB → multipart POST'); console.log('[HTTP] blob ' + (trimmedBlob.size/1024).toFixed(1) + 'KB → multipart POST');
try { try {
const r = await fetch('https://lang.kube5s.ru/openai/v1/audio/transcriptions', { const r = await fetch('https://lang.kube5s.ru/openai/v1/audio/transcriptions', {