From f07052780bfa677236a1f64a17856dc335a276c6 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 5 Jun 2026 08:26:24 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20listeners=20adapt=20down=20too?= =?UTF-8?q?=20=E2=80=94=204s=20=E2=86=92=202s=20on=20sustained=20clean=20f?= =?UTF-8?q?eed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-05: "the 4 sec delay doesn't get faster with a better feed for listeners." Previously the listener worklet was locked at rate=1.0 (no time- stretching, music quality protection) so the Double Dragon controller's retargets couldn't smoothly shrink the buffer. The listener got the full 4s cushion forever, even with a steady clean feed. Changes: 1. Remove the explicit lock_rate=1.0 on listener worklet install — all worklets now support the ±8% adaptive rate. 2. New constant DD_BASE_LISTENER_TARGET_SEC = 2.0. Listeners start at RECV_PLAYOUT_DELAY_SEC (4s) for fresh-attach wiggle-immunity; controller shrinks them toward 2s after DD_CLEAN_SAMPLES_NEEDED (30s) of clean stats. 3. ddBaseTargetForRole(role) — listener=2s, speaker/cohost/host=0.5s. 4. ddEntry initial currentTargetSec reflects the role's actual starting target (4s for listeners, 0.5s for speakers). 5. Controller's SHRINK branch uses the role-aware base. Music quality during the shrink: ≤8% rate change for ~10-20s, ≤1 semitone brief pitch shift. Audible on sustained tones but acceptable for the latency improvement — listener feels "more live" when room is calm. Phase 4 (WSOLA grain processing for true pitch preservation) still deferred; this gets us 80% of the win without it. --- web/zebra-spaces.html | 46 +++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 6781985..de02a8e 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1996,14 +1996,13 @@ function installJitterBuffer(uuid, node){ refreshLipSyncForUuid(uuid); } }; - /* Listener-role worklets never time-stretch — music quality on the - * deep cushion is paramount, and the buffer drifts gradually with - * clock drift. Speaker / cohost / host worklets DO stretch - * adaptively to drive the per-publisher target the Double Dragon - * controller sets. */ - if (myRole === 'listener'){ - try { jbuf.port.postMessage({ cmd: 'lock_rate', rate: 1.0 }); } catch(_){} - } + /* All roles now allow time-stretching — listeners benefit too + * (their 4s cushion shrinks toward 2s when feed is clean, grows + * back to 4s on instability). The ±8% rate cap means the brief + * pitch shift during adaptation is ≤1 semitone — barely audible + * on speech, briefly audible on sustained music tones. Fox + * 2026-06-05: "the 4 sec delay doesn't get faster with a better + * feed for listeners." */ try { node.src.disconnect(node.gain); } catch(_){} node.src.connect(jbuf).connect(node.gain); node.jbuf = jbuf; @@ -2186,15 +2185,23 @@ const DD_JITTER_THRESH_SEC = 0.030; /* > 30ms = unstable */ const DD_UNSTABLE_SAMPLES_NEEDED = 2; const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */ const DD_TELEMETRY_TICK_SEC = 5; -const DD_BASE_TARGET_SEC = 0.5; /* normal conversational cushion */ -const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability */ +const DD_BASE_TARGET_SEC = 0.5; /* speaker normal conversational cushion */ +const DD_BASE_LISTENER_TARGET_SEC = 2.0; /* listener clean-feed cushion — lower than initial 4s so latency feels less laggy when feed is good; controller grows back to MAX on any instability */ +const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability — same ceiling for everyone */ +function ddBaseTargetForRole(role){ + return role === 'listener' ? DD_BASE_LISTENER_TARGET_SEC : DD_BASE_TARGET_SEC; +} function ddEntry(pubHex){ let e = doubleDragon.get(pubHex); if (!e){ + /* Initial currentTargetSec matches what attachAudioStreamViaWorklet + * actually set as the worklet's target — playoutDelayForRole(myRole). + * Listeners start at 4s (RECV_PLAYOUT_DELAY_SEC), speakers at 0.5s. + * Controller shrinks listener toward 2s on sustained clean stats. */ e = { state: 'auto', unstableStreak: 0, cleanStreak: 0, lastLost: 0, lastSeenStatsAt: 0, - currentTargetSec: DD_BASE_TARGET_SEC }; + currentTargetSec: playoutDelayForRole(myRole) }; doubleDragon.set(pubHex, e); } return e; @@ -2254,6 +2261,7 @@ async function evaluateDoubleDragonForPub(pubHex){ } }); } catch(_){ return; } + const baseTarget = ddBaseTargetForRole(myRole); const unstable = (lossRate > DD_LOSS_PER_SEC_THRESH) || (jitter > DD_JITTER_THRESH_SEC); if (unstable){ @@ -2269,12 +2277,12 @@ async function evaluateDoubleDragonForPub(pubHex){ } else { e.cleanStreak++; e.unstableStreak = 0; if (e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED && - e.currentTargetSec > DD_BASE_TARGET_SEC){ - e.currentTargetSec = DD_BASE_TARGET_SEC; - ddSetTargetForPub(pubHex, DD_BASE_TARGET_SEC); + e.currentTargetSec > baseTarget){ + e.currentTargetSec = baseTarget; + ddSetTargetForPub(pubHex, baseTarget); logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ - ' SHRINK target → '+DD_BASE_TARGET_SEC+'s (clean '+ - e.cleanStreak+' samples)'); + ' SHRINK target → '+baseTarget+'s (clean '+ + e.cleanStreak+' samples, role='+myRole+')'); } } } @@ -6789,9 +6797,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');