zebra-spaces: predictive drain detector + self-calibrating floor (listener floor 1.3s)

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.
This commit is contained in:
Russell Ballestrini 2026-06-05 09:23:42 -04:00
parent c5ac9e1a3b
commit fa19f5df90
No known key found for this signature in database

View file

@ -1986,14 +1986,18 @@ function installJitterBuffer(uuid, node){
} else if (e.data.cmd === 'buffered'){ } else if (e.data.cmd === 'buffered'){
/* worklet's current buffer depth (seconds). Used by lip-sync /* worklet's current buffer depth (seconds). Used by lip-sync
* to make video receivers track the audio's total delay. * to make video receivers track the audio's total delay.
* stretchFactor is reported too — JS controller in Phase 3 * stretchFactor is reported too — JS controller uses it. */
* uses it to drive the per-publisher target. */
const n = listenerAudioNodes.get(uuid); const n = listenerAudioNodes.get(uuid);
if (n){ if (n){
n.bufferedSeconds = e.data.seconds; n.bufferedSeconds = e.data.seconds;
n.stretchFactor = e.data.stretchFactor; n.stretchFactor = e.data.stretchFactor;
} }
refreshLipSyncForUuid(uuid); 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 /* 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_UNSTABLE_SAMPLES_NEEDED = 2;
const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */ const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */
const DD_TELEMETRY_TICK_SEC = 5; const DD_TELEMETRY_TICK_SEC = 5;
const DD_BASE_TARGET_SEC = 0.5; /* speaker normal conversational cushion */ const DD_BASE_TARGET_SEC = 0.5; /* speaker minimum on stable feed */
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_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 */ 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){ function ddBaseTargetForRole(role){
return role === 'listener' ? DD_BASE_LISTENER_TARGET_SEC : DD_BASE_TARGET_SEC; 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){ function ddEntry(pubHex){
let e = doubleDragon.get(pubHex); let e = doubleDragon.get(pubHex);
@ -2198,14 +2221,73 @@ function ddEntry(pubHex){
/* Initial currentTargetSec matches what attachAudioStreamViaWorklet /* Initial currentTargetSec matches what attachAudioStreamViaWorklet
* actually set as the worklet's target — playoutDelayForRole(myRole). * actually set as the worklet's target — playoutDelayForRole(myRole).
* Listeners start at 4s (RECV_PLAYOUT_DELAY_SEC), speakers at 0.5s. * 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, e = { state: 'auto', unstableStreak: 0, cleanStreak: 0,
lastLost: 0, lastSeenStatsAt: 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); doubleDragon.set(pubHex, e);
} }
return 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){ function ddNoteManualToggle(pubHex, isOn){
const e = ddEntry(pubHex); const e = ddEntry(pubHex);
e.state = isOn ? 'manual-on' : 'manual-off'; e.state = isOn ? 'manual-on' : 'manual-off';
@ -2261,7 +2343,6 @@ async function evaluateDoubleDragonForPub(pubHex){
} }
}); });
} catch(_){ return; } } catch(_){ return; }
const baseTarget = ddBaseTargetForRole(myRole);
const unstable = (lossRate > DD_LOSS_PER_SEC_THRESH) || const unstable = (lossRate > DD_LOSS_PER_SEC_THRESH) ||
(jitter > DD_JITTER_THRESH_SEC); (jitter > DD_JITTER_THRESH_SEC);
if (unstable){ if (unstable){
@ -2270,19 +2351,27 @@ async function evaluateDoubleDragonForPub(pubHex){
e.currentTargetSec < DD_MAX_TARGET_SEC){ e.currentTargetSec < DD_MAX_TARGET_SEC){
e.currentTargetSec = DD_MAX_TARGET_SEC; e.currentTargetSec = DD_MAX_TARGET_SEC;
ddSetTargetForPub(pubHex, 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)+ logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
' GROW target → '+DD_MAX_TARGET_SEC+'s (loss/s='+ ' GROW target → '+DD_MAX_TARGET_SEC+'s (loss/s='+
lossRate.toFixed(1)+' jitter='+(jitter*1000).toFixed(0)+'ms)'); lossRate.toFixed(1)+' jitter='+(jitter*1000).toFixed(0)+'ms)');
} }
} else { } else {
e.cleanStreak++; e.unstableStreak = 0; 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 && if (e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED &&
e.currentTargetSec > baseTarget){ e.currentTargetSec > floor){
e.currentTargetSec = baseTarget; e.currentTargetSec = floor;
ddSetTargetForPub(pubHex, baseTarget); ddSetTargetForPub(pubHex, floor);
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
' SHRINK target → '+baseTarget+'s (clean '+ ' SHRINK target → '+floor.toFixed(2)+'s (clean '+
e.cleanStreak+' samples, role='+myRole+')'); 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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace"> <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> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-05</span><br> <span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-05</span><br>
md5 <span class="stamp-md5">1724c13cc98aa637c7462217cd46466d</span><br> md5 <span class="stamp-md5">dda84f98199acdcd1bb7102cd2878ec9</span><br>
sha256 <span class="stamp-sha">e6cb3dc549a0156290a35cd235fb2ec0ad6a9258a0d531eeb2e32c2589b186de</span><br> sha256 <span class="stamp-sha">6a59cafee418659fbaeb94c6e0af9014a7359841a09c880a245901bb4657ac3c</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">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> <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> </footer>