From fa19f5df904706875dba69cde729bc3d0603d08b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 5 Jun 2026 09:23:42 -0400 Subject: [PATCH] zebra-spaces: predictive drain detector + self-calibrating floor (listener floor 1.3s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-05: "both and see if we could lower to 1.3 secs as floor for listeners on the high quality stream." Two additions to the Double Dragon controller: 1. Predictive GROW (lead, not lag). The existing 5s-tick path watches loss/jitter — strictly a lagging indicator. We now ALSO watch the worklet's bufferedSeconds reports (every ~683ms) for rapid drain. If the buffer drops by more than 25% of its current target over a 1.5s window, GROW immediately — don't wait for loss to appear in the next 5s sample. Catches wiggle ~2s after it starts vs 5-10s on the lagging path. 2. Self-calibrating floor. Each publisher tracks its own maxWiggleDurationSec for the session. Floor for SHRINK is max(role-base, maxWiggle × 1.5). A stable publisher's listener can shrink to 1.3s. After observing a 1s wiggle the floor lifts to 1.5s; after a 4s wiggle, 6s (clamped to MAX). Resets when user leaves + rejoins. Constants: - DD_BASE_LISTENER_TARGET_SEC = 1.3 (was 2.0) - DD_WIGGLE_FLOOR_MULTIPLIER = 1.5 - DD_DRAIN_THRESH_FRAC = 0.25 - DD_DRAIN_WINDOW_SEC = 1.5 Telemetry log lines: - "double-dragon pub=XXXX PREDICTIVE GROW → 4s (buf drained Nms in N.Ns)" - "double-dragon pub=XXXX wiggle ended dur=N.Ns — floor now N.Ns" - "double-dragon pub=XXXX SHRINK target → N.Ns (clean N samples, role=X, max-wiggle=N.Ns)" This is the "lead + lag, AND self-tuned floor" loop. The 1.3s listener floor only applies to clean-history sessions. A wiggle-prone publisher will see the floor stay elevated automatically. --- web/zebra-spaces.html | 117 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 14 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index f708c4e..b67d5e8 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1986,14 +1986,18 @@ function installJitterBuffer(uuid, node){ } else if (e.data.cmd === 'buffered'){ /* worklet's current buffer depth (seconds). Used by lip-sync * to make video receivers track the audio's total delay. - * stretchFactor is reported too — JS controller in Phase 3 - * uses it to drive the per-publisher target. */ + * stretchFactor is reported too — JS controller uses it. */ const n = listenerAudioNodes.get(uuid); if (n){ n.bufferedSeconds = e.data.seconds; n.stretchFactor = e.data.stretchFactor; } refreshLipSyncForUuid(uuid); + /* Predictive drain detector — feed the Double Dragon + * controller's lead-indicator path. ~683ms cadence is fast + * enough to catch a wiggle within ~2s of it starting, + * vs the 5s tick path which can be 5-10s late. */ + ddNoteWorkletBuffered(uuid, e.data.seconds); } }; /* All roles now allow time-stretching — listeners benefit too @@ -2185,12 +2189,31 @@ 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; /* 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_BASE_TARGET_SEC = 0.5; /* speaker minimum on stable feed */ +const DD_BASE_LISTENER_TARGET_SEC = 1.3; /* listener minimum on stable high-quality feed — fox 2026-06-05 */ const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability — same ceiling for everyone */ +/* Self-calibrating floor: after a wiggle, the floor for THAT publisher + * lifts to wiggleDurationSec × DD_WIGGLE_FLOOR_MULTIPLIER. e.g. an + * observed 1.5s wiggle → 2.25s floor. The floor only rises within a + * session; resets when the user leaves + rejoins. */ +const DD_WIGGLE_FLOOR_MULTIPLIER = 1.5; +/* Predictive drain detector: if the worklet's bufferedSeconds drops + * by more than this fraction of its target over DD_DRAIN_WINDOW_SEC, + * GROW immediately — don't wait for the 5s loss/jitter sample. This + * is the "lead" half of the lead+lag system fox asked for. */ +const DD_DRAIN_THRESH_FRAC = 0.25; +const DD_DRAIN_WINDOW_SEC = 1.5; function ddBaseTargetForRole(role){ return role === 'listener' ? DD_BASE_LISTENER_TARGET_SEC : DD_BASE_TARGET_SEC; } +/* Self-calibrating floor — max(role base, observed-wiggle × 1.5) */ +function ddEffectiveFloorForPub(pubHex){ + const base = ddBaseTargetForRole(myRole); + const e = doubleDragon.get(pubHex); + if (!e) return base; + const adaptive = (e.maxWiggleDurationSec || 0) * DD_WIGGLE_FLOOR_MULTIPLIER; + return Math.max(base, adaptive); +} function ddEntry(pubHex){ let e = doubleDragon.get(pubHex); @@ -2198,14 +2221,73 @@ function ddEntry(pubHex){ /* 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. */ + * Controller shrinks listener toward DD_BASE_LISTENER_TARGET_SEC + * on sustained clean stats. */ e = { state: 'auto', unstableStreak: 0, cleanStreak: 0, lastLost: 0, lastSeenStatsAt: 0, - currentTargetSec: playoutDelayForRole(myRole) }; + currentTargetSec: playoutDelayForRole(myRole), + /* predictive drain detector: rolling history of buffered + * seconds reports from the worklet (every ~683ms). 4 entries + * ≈ 2.7s window — covers DD_DRAIN_WINDOW_SEC plus margin. */ + bufferedHistory: [], + /* self-calibrating floor state */ + maxWiggleDurationSec: 0, + wiggleStartedAt: 0 }; doubleDragon.set(pubHex, e); } return e; } + +/* Called from the worklet's 'buffered' port message. Tracks the + * recent bufferedSeconds for predictive drain detection (lead) and + * records wiggle-event durations for the self-calibrating floor. */ +function ddNoteWorkletBuffered(uuid, bufferedSec){ + /* uuid → pubHex */ + const mm = members.get(uuid); + if (!mm || !mm.pubkey) return; + let pubHex; + try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ return; } + const e = ddEntry(pubHex); + if (e.state === 'manual-on' || e.state === 'manual-off') return; + const now = Date.now(); + e.bufferedHistory.push({ t: now, v: bufferedSec }); + while (e.bufferedHistory.length > 0 && + (now - e.bufferedHistory[0].t) / 1000 > DD_DRAIN_WINDOW_SEC + 1.0){ + e.bufferedHistory.shift(); + } + /* Predictive drain → GROW. If the buffer has dropped by more than + * DD_DRAIN_THRESH_FRAC of the current target across the window, + * something upstream is starving us. Lift the target NOW, before + * loss appears in the next 5s tick. */ + if (e.bufferedHistory.length >= 3 && e.currentTargetSec < DD_MAX_TARGET_SEC){ + const oldest = e.bufferedHistory[0]; + const newest = e.bufferedHistory[e.bufferedHistory.length - 1]; + const dt = (newest.t - oldest.t) / 1000; + const dv = newest.v - oldest.v; + if (dt >= DD_DRAIN_WINDOW_SEC * 0.6 && + dv < -e.currentTargetSec * DD_DRAIN_THRESH_FRAC){ + e.currentTargetSec = DD_MAX_TARGET_SEC; + ddSetTargetForPub(pubHex, DD_MAX_TARGET_SEC); + e.wiggleStartedAt = now; + logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ + ' PREDICTIVE GROW → '+DD_MAX_TARGET_SEC+'s (buf drained '+ + Math.abs(dv*1000).toFixed(0)+'ms in '+dt.toFixed(1)+'s)'); + } + } + /* Wiggle recovery: if a wiggle had been recorded and the buffer + * comes back up to a healthy fraction of target, end the wiggle + * and use its duration to lift the self-calibrating floor. */ + if (e.wiggleStartedAt > 0 && bufferedSec >= e.currentTargetSec * 0.85){ + const dur = (now - e.wiggleStartedAt) / 1000; + e.wiggleStartedAt = 0; + if (dur > e.maxWiggleDurationSec){ + e.maxWiggleDurationSec = dur; + logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ + ' wiggle ended dur='+dur.toFixed(1)+'s — floor now '+ + ddEffectiveFloorForPub(pubHex).toFixed(2)+'s'); + } + } +} function ddNoteManualToggle(pubHex, isOn){ const e = ddEntry(pubHex); e.state = isOn ? 'manual-on' : 'manual-off'; @@ -2261,7 +2343,6 @@ 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){ @@ -2270,19 +2351,27 @@ async function evaluateDoubleDragonForPub(pubHex){ e.currentTargetSec < DD_MAX_TARGET_SEC){ e.currentTargetSec = DD_MAX_TARGET_SEC; ddSetTargetForPub(pubHex, DD_MAX_TARGET_SEC); + if (e.wiggleStartedAt === 0) e.wiggleStartedAt = Date.now(); logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ ' GROW target → '+DD_MAX_TARGET_SEC+'s (loss/s='+ lossRate.toFixed(1)+' jitter='+(jitter*1000).toFixed(0)+'ms)'); } } else { e.cleanStreak++; e.unstableStreak = 0; + /* Self-calibrating floor: a publisher that's caused a 2s wiggle + * this session can't shrink below 3s; one that's been clean the + * whole session can shrink to the role's base. fox 2026-06-05: + * predictive lead + self-calibrating floor lets listeners hit + * 1.3s on a clean high-quality stream. */ + const floor = ddEffectiveFloorForPub(pubHex); if (e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED && - e.currentTargetSec > baseTarget){ - e.currentTargetSec = baseTarget; - ddSetTargetForPub(pubHex, baseTarget); + e.currentTargetSec > floor){ + e.currentTargetSec = floor; + ddSetTargetForPub(pubHex, floor); logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ - ' SHRINK target → '+baseTarget+'s (clean '+ - e.cleanStreak+' samples, role='+myRole+')'); + ' SHRINK target → '+floor.toFixed(2)+'s (clean '+ + e.cleanStreak+' samples, role='+myRole+ + ', max-wiggle='+e.maxWiggleDurationSec.toFixed(1)+'s)'); } } } @@ -6798,8 +6887,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');