From 1c52e09b37d14c38b2c72f15e0b9e8ea2ce5745a Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 5 Jun 2026 13:42:14 -0400 Subject: [PATCH] zebra-spaces: anti-alias + compressor on self-transcribe (host accuracy fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-05: "the remote phones are transcribing more accurate than the host version. figure out why and make host version better." Two causes diagnosed: 1. Aliasing. The whisper-capture worklet decimates 48 kHz → 16 kHz by taking every 3rd sample with NO anti-alias filter. Listener path feeds Opus-decoded peer audio which is already band-limited (~12 kHz max). Host path feeds RAW mic — all 48 kHz of it — which means anything above 8 kHz folds back into the audible band as garbage when decimated. Whisper sees noisier input on host. 2. Amplitude / dynamic range. Music-mode mic has NO AGC/NS/EC (raw broadcast for music). Quiet passages are too low for Whisper to confidently transcribe; loud passages can clip. The listener path's audio has been through Opus encode/decode which normalizes implicitly. Both addressed via Web Audio nodes inserted between the MediaStreamSource and the whisper-capture worklet: Self-capture chain: micStream → DynamicsCompressorNode → BiquadFilter (lowpass 7 kHz) → AudioWorkletNode (whisper-capture) DynamicsCompressor: voice-friendly settings (threshold -30 dB, knee 30, ratio 4:1, attack 3 ms, release 250 ms). Flattens amplitude so Whisper sees a normalized waveform regardless of how the user has the mic gain set or whether music mode is on. BiquadFilter lowpass at 7 kHz (below Nyquist of 16 kHz = 8 kHz): removes content that would alias when the worklet decimates. Remote captures: just the lowpass (no compressor — Opus has already normalized the peer audio adequately). Mostly a no-op on voice-mode peers (Opus already cuts <12 kHz) but helps music-mode peers where the encode preserves more high-frequency content. stopCaptureForUuid / stopSelfCapture cleanly disconnect the new nodes so re-toggle doesn't leak audio graph references. Expected: host self-transcribe accuracy now comparable to (or better than) the phone listener transcribes. Telemetry `emt=` should increase per tick on host once the user is talking. --- web/zebra-spaces.html | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) 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');