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');