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');