From fd2d927b5e0592fefcd9a555b449b032e0a8b7ac Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 15:34:56 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20worklet=20sticky-started=20?= =?UTF-8?q?=E2=80=94=20tolerate=20brief=20drains,=20don't=20re-buffer=204s?= =?UTF-8?q?=20on=20every=20hiccup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-04: "delay does seem to be about 4 secs but still very chappy." Buffer IS holding 4s and emitting — that part works — but every transient empty-queue tick (a single 2.67 ms drain) was setting started=false, which forced a full 4-second re-fill before emit resumed. So a 50 ms network jitter on the upstream caused a 4 s silence on the listener. That's the chop. Fix: track consecutive empty-queue blocks. Only re-arm (started=false) after rearmThresholdBlocks (100 = ~267 ms) of sustained silence. Brief drains emit silence-fill but keep started=true so playback resumes the instant new samples arrive. Listener hears at most ~267 ms of dead air on each drain — almost certainly Opus PLC will mask far shorter ones. Long outages (>267 ms with no samples) still re-buffer to 4 s — that case isn't this bug, it's a real upstream death where a fresh cushion is correct. --- web/zebra-spaces.html | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index c61aaf8..8cf4b9a 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1774,11 +1774,17 @@ class JitterBufferProcessor extends AudioWorkletProcessor { this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5); this.targetSamples = Math.round(this.targetSeconds * sampleRate); this.maxSamples = Math.round(this.maxSeconds * sampleRate); + /* re-arm only after this many consecutive empty blocks. 128 samples + * per block at 48 kHz = 2.67 ms; 100 blocks ≈ 267 ms of silence. + * brief upstream drains (a single empty process() tick) MUST NOT + * tear down playback, or a 4s re-buffer kicks in every time — + * which is what made the phone choppy. */ + this.rearmThresholdBlocks = 100; this.queue = []; this.buffered = 0; this.started = false; + this.emptyStreak = 0; this.dropped = 0; - this.starved = 0; } process(inputs, outputs){ const inBlk = inputs[0]; @@ -1800,21 +1806,28 @@ class JitterBufferProcessor extends AudioWorkletProcessor { this.dropped += drop[0].length; } } - /* lock onto the buffer once it fills; re-arm if we ever fully - * drain so a brief upstream outage doesn't lock us into a - * silent state */ + /* lock onto the buffer once it fills. Do NOT un-lock on a single + * empty queue tick — that's what made the phone choppy: any + * 2.67ms drain forced a full 4s re-buffer. emptyStreak tracks + * sustained silence and only re-arms after ~267ms. */ if (!this.started && this.buffered >= this.targetSamples) this.started = true; - else if (this.started && this.queue.length === 0) this.started = false; if (this.started && this.queue.length > 0){ const head = this.queue.shift(); this.buffered -= head[0].length; + this.emptyStreak = 0; for (let c = 0; c < nch; c++){ const srcCh = head[c] || head[0]; /* mono → stereo: dup L→R */ outBlk[c].set(srcCh.subarray(0, outBlk[c].length)); } } else { for (let c = 0; c < nch; c++) outBlk[c].fill(0); - if (!this.started && this.buffered > 0) this.starved++; + if (this.started){ + this.emptyStreak++; + if (this.emptyStreak >= this.rearmThresholdBlocks){ + this.started = false; + this.emptyStreak = 0; + } + } } return true; } @@ -6098,8 +6111,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');