zebra-spaces: anti-alias + compressor on self-transcribe (host accuracy fix)
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.
This commit is contained in:
parent
9345975c58
commit
1c52e09b37
1 changed files with 35 additions and 6 deletions
|
|
@ -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');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-05</span><br>
|
||||
md5 <span class="stamp-md5">2735b0ba85cac127a1dee3bdc5ecd88e</span><br>
|
||||
sha256 <span class="stamp-sha">725573c39710827dda977eb9dbcf13d05fad6cc73ba694448d3886f63810bd80</span><br>
|
||||
md5 <span class="stamp-md5">e2a9c5eb310147cb147f54dc2495f3f6</span><br>
|
||||
sha256 <span class="stamp-sha">076444a5567d418cb48557ee1b1e0c88539534a1532df4d6f6be98a7e40b0343</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue