diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 131aca5..7d2330d 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2367,14 +2367,29 @@ async function startSelfCapture(){ if (!ok){ logLine('err','self capture: worklet module load failed'); return; } try { const src = audioCtx.createMediaStreamSource(micStream); + /* DynamicsCompressorNode — normalize amplitude before Whisper. + * Host raw mic (especially in music mode with no AGC) has wide + * dynamic range; Whisper accuracy suffers on quiet/clipped + * passages. Compressor flattens it. Voice-friendly settings. */ + const comp = audioCtx.createDynamicsCompressor(); + comp.threshold.value = -30; comp.knee.value = 30; + comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.25; + /* BiquadFilter lowpass @ 7 kHz — anti-alias before the worklet's + * 48k→16k decimation. Mic content >8 kHz otherwise folds into + * the audible band as noise and degrades Whisper. Listener path + * doesn't need this because Opus has already band-limited the + * peer audio. Fox 2026-06-05: "the remote phones are transcribing + * more accurate than the host version." */ + const lpf = audioCtx.createBiquadFilter(); + lpf.type = 'lowpass'; lpf.frequency.value = 7000; lpf.Q.value = 0.707; const cap = new AudioWorkletNode(audioCtx, 'whisper-capture'); cap.port.onmessage = (e) => { if (e.data && e.data.chunk) handleWhisperChunk(myUUID, e.data.chunk); }; - src.connect(cap); + src.connect(comp).connect(lpf).connect(cap); cap.port.postMessage({ cmd: 'start' }); - _selfCapture = { src, cap }; - logLine('', 'whisper: self capture started'); + _selfCapture = { src, comp, lpf, cap }; + logLine('', 'whisper: self capture started (anti-alias + compressor)'); } catch (e){ logLine('err', 'whisper self capture: '+e.message); } @@ -2383,6 +2398,8 @@ function stopSelfCapture(){ if (!_selfCapture) return; try { _selfCapture.cap.port.postMessage({ cmd: 'stop' }); } catch(_){} try { _selfCapture.cap.disconnect(); } catch(_){} + try { _selfCapture.lpf.disconnect(); } catch(_){} + try { _selfCapture.comp.disconnect(); } catch(_){} try { _selfCapture.src.disconnect(); } catch(_){} _selfCapture = null; } @@ -2393,6 +2410,13 @@ async function startCaptureForUuid(uuid){ const ok = await loadWhisperCaptureWorklet(audioCtx); if (!ok) return; try { + /* Same anti-alias lowpass as self-capture. Opus has already + * band-limited remote audio, so this is usually a no-op — but + * the music-mode peer can push wideband Opus close to 12 kHz + * and the decimation still aliases. Cheap to apply + * unconditionally. */ + const lpf = audioCtx.createBiquadFilter(); + lpf.type = 'lowpass'; lpf.frequency.value = 7000; lpf.Q.value = 0.707; const capture = new AudioWorkletNode(audioCtx, 'whisper-capture'); capture.port.onmessage = (e) => { if (e.data && e.data.chunk) handleWhisperChunk(uuid, e.data.chunk); @@ -2400,9 +2424,10 @@ async function startCaptureForUuid(uuid){ /* tap the source — capture runs in parallel with the worklet * chain, doesn't need to connect to destination (we just want * the chunks via port messages). */ - node.src.connect(capture); + node.src.connect(lpf).connect(capture); capture.port.postMessage({ cmd: 'start' }); node.capture = capture; + node.captureLpf = lpf; logLine('', 'whisper: capture started for '+uuid.slice(0,4)); } catch (e){ logLine('err', 'whisper capture install '+uuid.slice(0,4)+': '+e.message); @@ -2414,6 +2439,10 @@ function stopCaptureForUuid(uuid){ if (!node || !node.capture) return; try { node.capture.port.postMessage({ cmd: 'stop' }); } catch(_){} try { node.capture.disconnect(); } catch(_){} + if (node.captureLpf){ + try { node.captureLpf.disconnect(); } catch(_){} + node.captureLpf = null; + } try { node.src.disconnect(node.capture); } catch(_){} node.capture = null; } @@ -7427,8 +7456,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');