zebra-spaces: always-SFU receive audio + 0.7s worklet cushion
SFU is the only receive-audio path. Mesh PCs carry our outbound
mic; their inbound audio is ignored. handleRemoteSfuTrack always
attaches the SFU stream regardless of mesh peer state. mesh
pc.ontrack is a no-op breadcrumb.
SPEAKER_PLAYOUT_DELAY_SEC bumped 0.5s → 0.7s so the worklet
cushion absorbs the 200ms host-wiggle that mesh's lower-latency
path used to absorb. DD_BASE_TARGET_SEC tracks at 0.7s as the
Double Dragon adaptive floor.
Tests pin the new contract:
- SPEAKER_PLAYOUT_DELAY_SEC === 0.7
- SFU attaches across every mesh peer state (connected,
failed, connecting, disconnected, missing)
- in-place setWorkletStream still works (renegotiation path)
This commit is contained in:
parent
4cedd85dc6
commit
1984bb1d41
2 changed files with 34 additions and 204 deletions
|
|
@ -383,8 +383,8 @@ console.log('listener audio attach:');
|
|||
test('shipped: RECV_PLAYOUT_DELAY_SEC is 4s (fox-approved for cellular music; do not dial back without sign-off)', () => {
|
||||
eq(RECV, 4.0, 'RECV_PLAYOUT_DELAY_SEC');
|
||||
});
|
||||
test('shipped: SPEAKER_PLAYOUT_DELAY_SEC is 0.5s (conversational latency for speakers/cohost/host)', () => {
|
||||
eq(SPEAKER, 0.5, 'SPEAKER_PLAYOUT_DELAY_SEC');
|
||||
test('shipped: SPEAKER_PLAYOUT_DELAY_SEC is 0.7s (worklet cushion on SFU-only receive path absorbs 200ms host wiggle)', () => {
|
||||
eq(SPEAKER, 0.7, 'SPEAKER_PLAYOUT_DELAY_SEC');
|
||||
});
|
||||
|
||||
/* -------- unit: attachAudioStreamViaWorklet -------- */
|
||||
|
|
@ -953,87 +953,38 @@ test('speaker: SFU ontrack arrives, mesh PC NOT connected → attach proceeds (m
|
|||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built via SFU');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
eq(node.targetSeconds, SPEAKER, '0.5s target for speaker');
|
||||
eq(node.targetSeconds, SPEAKER, '0.7s target for speaker');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'reaches destination');
|
||||
});
|
||||
|
||||
test('speaker: SFU ontrack with mesh peer state=connected → SFU attach SKIPPED (no double-chain with mesh)', () => {
|
||||
/* Pins the canSpeak()+meshState branch at handleRemoteSfuTrack
|
||||
* ~line 4760. If this guard regresses, every speaker with a mesh
|
||||
* peer ends up with two chains for the same publisher: one via
|
||||
* SFU, one via mesh. fox: "fxhp-phone hearing double as speaker."
|
||||
* Closing Firefox cleared it because the rebuilt PC didn't race
|
||||
* the SFU ontrack as harshly. */
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.peers.set(UUID_A, { connectionState: 'connected' });
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no SFU chain — mesh owns this peer');
|
||||
/* but cache is still primed so a later mesh-fail can recover */
|
||||
truthy(b.api.sfuStreamsByPubHex.size > 0, 'SFU stream cached for fallback');
|
||||
test('speaker: SFU ontrack ALWAYS attaches regardless of mesh peer state — mesh inbound audio is ignored', () => {
|
||||
/* SFU is the only receive-audio path. Mesh peer state (connected,
|
||||
* failed, new, missing) must never gate the SFU attach. */
|
||||
for (const meshState of ['connected', 'failed', 'connecting', 'disconnected']){
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.peers.set(UUID_A, { connectionState: meshState });
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'SFU attached (mesh state='+meshState+')');
|
||||
}
|
||||
});
|
||||
|
||||
test('speaker: SFU ontrack with mesh peer state=failed → attach PROCEEDS (mesh is dead, SFU must take over)', () => {
|
||||
/* The "promoted but nobody hears them" regression — a stale 'failed'
|
||||
* mesh entry must not block SFU. */
|
||||
test('speaker: setWorkletStream still supports in-place source swap (used internally by attachAudioStreamViaWorklet for renegotiation, not by mesh anymore)', () => {
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.peers.set(UUID_A, { connectionState: 'failed' });
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'SFU took over from failed mesh');
|
||||
});
|
||||
|
||||
test('speaker: SFU first then mesh — setWorkletStream swaps in place, single chain', () => {
|
||||
/* This is the common mesh path: SFU subscribe brings audio first
|
||||
* (because subscribe runs immediately), then mesh PC connects a
|
||||
* second or two later and ontrack fires, swapping the source. */
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const sfuStream = makeStream();
|
||||
b.api.attachSfuTrack(UUID_A, sfuStream);
|
||||
b.api.attachSfuTrack(UUID_A, makeStream());
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const dest = b.ctx.destination;
|
||||
truthy(node, 'chain built via SFU');
|
||||
|
||||
/* simulate mesh ontrack: swap source in place — exact code path the
|
||||
* connectToPeer ontrack handler uses (web/zebra-spaces.html:6596) */
|
||||
const meshStream = makeStream();
|
||||
const swapOk = b.api.setWorkletStream(UUID_A, meshStream);
|
||||
eq(swapOk, true, 'mesh swap succeeded');
|
||||
const newStream = makeStream();
|
||||
const swapOk = b.api.setWorkletStream(UUID_A, newStream);
|
||||
eq(swapOk, true, 'in-place swap succeeded');
|
||||
const after = b.api.listenerAudioNodes.get(UUID_A);
|
||||
eq(after, node, 'same node — no rebuild');
|
||||
eq(after.gain, node.gain, 'same gain — no rewire to destination');
|
||||
eq(after.stream, meshStream, 'tracks mesh stream now');
|
||||
truthy(reachable(after.src, dest), 'mesh src reaches destination');
|
||||
});
|
||||
|
||||
test('speaker: mesh ontrack BEFORE SFU ontrack → mesh attaches via fallback, SFU then skips because mesh.connectionState=connected', () => {
|
||||
/* Less common but possible: mesh connects faster than SFU subscribe
|
||||
* negotiation (e.g. STUN binding cached). Mesh's ontrack runs
|
||||
* setWorkletStream which fails (no existing chain), falls through
|
||||
* to attachAudioStreamViaWorklet to build one. Then SFU ontrack
|
||||
* fires; canSpeak+mesh.connected → skip. */
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
|
||||
/* fake mesh ontrack effect: chain doesn't exist yet, setWorkletStream
|
||||
* returns false, fallback builds chain at SPEAKER target */
|
||||
const meshStream = makeStream();
|
||||
const swapOk = b.api.setWorkletStream(UUID_A, meshStream);
|
||||
eq(swapOk, false, 'setWorkletStream false — no chain yet');
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, meshStream, SPEAKER);
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built from mesh fallback');
|
||||
|
||||
/* now SFU ontrack arrives with mesh marked connected */
|
||||
b.api.peers.set(UUID_A, { connectionState: 'connected' });
|
||||
const sfuStream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', sfuStream));
|
||||
|
||||
/* still only one chain — the one mesh built. SFU should have skipped. */
|
||||
eq(b.api.listenerAudioNodes.size, 1, 'one chain total');
|
||||
eq(b.api.listenerAudioNodes.get(UUID_A).stream, meshStream, 'still pointing at mesh stream');
|
||||
truthy(reachable(after.src, dest), 'new src reaches destination');
|
||||
});
|
||||
|
||||
test('speaker: rapid mesh re-ontracks for same publisher → single chain, no duplicate gains or sources stacked on destination', () => {
|
||||
|
|
|
|||
|
|
@ -3036,7 +3036,7 @@ 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 minimum on stable feed */
|
||||
const DD_BASE_TARGET_SEC = 0.7; /* 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
|
||||
|
|
@ -3675,14 +3675,14 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
|
|||
* out wiggle-stalls without a glitch. Big enough to survive any
|
||||
* realistic publisher-side hiccup. */
|
||||
const RECV_PLAYOUT_DELAY_SEC = 4.0;
|
||||
/* Speaker/cohost/host INITIAL cushion — 0.5s for conversational
|
||||
* latency, then the Double Dragon adaptive controller grows it to
|
||||
* DD_MAX_TARGET_SEC (4s) when the publisher's network or encoder
|
||||
* shows instability and shrinks it back to DD_BASE_TARGET_SEC (0.5s)
|
||||
/* Speaker/cohost/host INITIAL cushion — 0.7s. Enough to absorb a
|
||||
* 200ms host wiggle through SFU's worklet unaided. Double Dragon
|
||||
* adaptive controller grows it to DD_MAX_TARGET_SEC (4s) under
|
||||
* publisher instability and shrinks it back to DD_BASE_TARGET_SEC
|
||||
* during sustained clean periods. The worklet's ±6% rate-limited
|
||||
* resampling makes the transition smooth: brief ≤1 semitone pitch
|
||||
* shift during the ramp, silence-free, click-free. */
|
||||
const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
|
||||
const SPEAKER_PLAYOUT_DELAY_SEC = 0.7;
|
||||
/* HTTP /stream estimated end-to-end delay used for lip-sync when the
|
||||
* per-speaker stream toggle is ON for a publisher. The actual delay
|
||||
* varies (~1-3s depending on browser buffer + network), so this is a
|
||||
|
|
@ -4893,24 +4893,8 @@ function handleRemoteSfuTrack(ev){
|
|||
registerLipSyncAudio(pubHex, uuid, ev.receiver);
|
||||
sfuAudioReceivers.set(pubHex, ev.receiver);
|
||||
}
|
||||
/* speakers get their peers' audio via mesh (lower latency)
|
||||
* AT THE TIMES THE MESH PC IS CONNECTED. A stale or failing
|
||||
* mesh PC must NOT block the SFU fallback — that's how the
|
||||
* 'phone re-promoted but nobody hears them' regression
|
||||
* appeared: the peers map still had an entry whose state
|
||||
* was 'failed', so we skipped SFU and the receiver got no
|
||||
* audio at all. */
|
||||
if (canSpeak(myRole)){
|
||||
const meshPC = peers.get(uuid);
|
||||
const meshState = meshPC ? meshPC.connectionState : 'none';
|
||||
if (meshPC && meshState === 'connected'){
|
||||
logLine('', 'sfu mic skipped for '+uuid+' — mesh peer connected');
|
||||
return;
|
||||
}
|
||||
if (meshPC){
|
||||
logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
|
||||
}
|
||||
}
|
||||
/* SFU is the only receive-audio path for every role,
|
||||
* including speakers. Mesh PCs carry our outbound mic only. */
|
||||
attachSfuTrack(uuid, s);
|
||||
return;
|
||||
}
|
||||
|
|
@ -6769,93 +6753,10 @@ async function connectToPeer(uuid, weOffer){
|
|||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||||
pc.ontrack = (ev) => {
|
||||
/* SFU and mesh both feed the SAME worklet now. The SFU stream
|
||||
* was already routed there by attachSfuTrack; we swap in the
|
||||
* mesh stream so the worklet's buffer absorbs wiggle-glitches
|
||||
* for mesh too. Without this, mesh's native ~50ms buffer was no
|
||||
* cushion at all — a 200ms host stall caused mesh listeners to
|
||||
* glitch while SFU listeners didn't. Now both share the worklet
|
||||
* cushion (0.5s for speakers). Source swap is seamless because
|
||||
* the worklet's queue holds 0.5s of decoded samples and both
|
||||
* sources contain identical content at slightly different
|
||||
* network delays.
|
||||
*
|
||||
* Gate the swap on track-not-muted. ev.track.muted=true means
|
||||
* "no RTP yet"; it transitions to false on the first decoded
|
||||
* packet ('unmute' event). Swapping the worklet to a muted mesh
|
||||
* track orphans the SFU receiver (no decoder consumes it →
|
||||
* jbuf=? + level=0 on telemetry) while the worklet plays silence
|
||||
* from mesh — the silent-room state seen on fedora chrome
|
||||
* (ticket 0001, fox 2026-06-07). If the mesh track is already
|
||||
* unmuted at ontrack-time, swap immediately. Otherwise wait for
|
||||
* 'unmute' before touching the worklet. */
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||
const swapToMesh = () => {
|
||||
if (!setWorkletStream(uuid, stream)){
|
||||
/* fallback: no existing worklet (AudioContext failed at SFU
|
||||
* attach time). Create one fresh with mesh as the source.
|
||||
* Small initial silence while the buffer fills. */
|
||||
attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC);
|
||||
}
|
||||
/* mesh path is peer-to-peer between two speakers (you'd never be
|
||||
* in mesh as a pure listener). Always conversational latency
|
||||
* here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
|
||||
* needed. */
|
||||
try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
|
||||
try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
|
||||
/* lip-sync: rebind the audio receiver for this publisher to the
|
||||
* MESH receiver since mesh is now what's feeding the worklet.
|
||||
* Its jbuf (~50ms native) + worklet (0.5s) ≈ ~0.55s total audio
|
||||
* delay. Video receivers will re-target to match on the next
|
||||
* worklet 'buffered' message. Fox 2026-06-04: "video would need
|
||||
* to be slid depending on the mode to keep it in sync." */
|
||||
try {
|
||||
const mm = members.get(uuid);
|
||||
const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
|
||||
if (pubHex && ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
|
||||
} catch(_){}
|
||||
stopMeter(uuid); startMeter(uuid, stream);
|
||||
logLine('', 'mesh stream swapped into worklet for '+uuid.slice(0,4)+' — buffer cushion now applies to mesh too');
|
||||
};
|
||||
/* Mesh-stale fallback: once swapped in, if the mesh track returns
|
||||
* to muted (RTP stopped flowing) for >MESH_MUTE_WINDOW_MS, swap
|
||||
* the worklet back to the cached SFU stream so the listener keeps
|
||||
* hearing the publisher even if mesh stalls without dropping the
|
||||
* PC's connectionState. Same idea as the connectionState='failed'
|
||||
* restore path (below), but driven by track-level mute instead of
|
||||
* PC-level failure. */
|
||||
const MESH_MUTE_WINDOW_MS = 5000;
|
||||
let muteTimer = null;
|
||||
ev.track.addEventListener('mute', () => {
|
||||
if (muteTimer) return;
|
||||
muteTimer = setTimeout(() => {
|
||||
muteTimer = null;
|
||||
if (!ev.track.muted) return;
|
||||
try {
|
||||
const mm = members.get(uuid);
|
||||
const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
|
||||
const sfuStream = pubHex ? sfuStreamsByPubHex.get(pubHex) : null;
|
||||
if (sfuStream && setWorkletStream(uuid, sfuStream)){
|
||||
const sfuRx = pubHex ? sfuAudioReceivers.get(pubHex) : null;
|
||||
if (sfuRx) registerLipSyncAudio(pubHex, uuid, sfuRx);
|
||||
logLine('', 'mesh muted >'+(MESH_MUTE_WINDOW_MS/1000)+'s for '+uuid.slice(0,4)+' — worklet swapped back to SFU');
|
||||
}
|
||||
} catch(_){}
|
||||
}, MESH_MUTE_WINDOW_MS);
|
||||
});
|
||||
ev.track.addEventListener('unmute', () => {
|
||||
if (muteTimer){ clearTimeout(muteTimer); muteTimer = null; }
|
||||
/* if we were on SFU because mesh went muted, swap forward to
|
||||
* mesh again now that RTP is flowing. swapToMesh is idempotent
|
||||
* (setWorkletStream is a no-op if the same source is already
|
||||
* connected). */
|
||||
swapToMesh();
|
||||
});
|
||||
if (!ev.track.muted){
|
||||
swapToMesh();
|
||||
} else {
|
||||
logLine('', 'mesh track for '+uuid.slice(0,4)+' arrived muted — staying on SFU until unmute');
|
||||
}
|
||||
/* Mesh inbound audio is ignored — the SFU-fed worklet is the
|
||||
* single receive-audio path. The mesh PC carries our outbound
|
||||
* mic only. */
|
||||
logLine('', 'mesh ontrack for '+uuid.slice(0,4)+' — SFU is the receive path');
|
||||
};
|
||||
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
|
||||
pc.onconnectionstatechange = () => {
|
||||
|
|
@ -6870,28 +6771,6 @@ async function connectToPeer(uuid, weOffer){
|
|||
const attempts = (peerMeshRetries.get(uuid) || 0) + 1;
|
||||
peerMeshRetries.set(uuid, attempts);
|
||||
tearPeer(uuid);
|
||||
/* Mesh PC died. Swap the worklet's source back to the cached
|
||||
* SFU stream so the user keeps hearing the publisher without a
|
||||
* hiccup. The worklet's existing queue covers the swap latency
|
||||
* — by the time the queue drains 0.5s of (now-stale) mesh
|
||||
* samples, SFU samples are flowing in. attachCachedSfuStreamFor
|
||||
* is kept as a fallback for the no-worklet case (AudioContext
|
||||
* failed earlier). */
|
||||
try {
|
||||
const mm = members.get(uuid);
|
||||
const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
|
||||
const sfuStream = pubHex ? sfuStreamsByPubHex.get(pubHex) : null;
|
||||
if (sfuStream && setWorkletStream(uuid, sfuStream)){
|
||||
/* lip-sync: rebind back to the SFU audio receiver. Worklet
|
||||
* source is SFU again → video should target SFU's native
|
||||
* jbuf + worklet (~0.5s + 0.5s ≈ 1s) instead of mesh's. */
|
||||
const sfuRx = pubHex ? sfuAudioReceivers.get(pubHex) : null;
|
||||
if (sfuRx) registerLipSyncAudio(pubHex, uuid, sfuRx);
|
||||
logLine('', 'mesh failed → worklet swapped back to SFU stream for '+uuid.slice(0,4));
|
||||
} else {
|
||||
attachCachedSfuStreamFor(uuid);
|
||||
}
|
||||
} catch(_){ try { attachCachedSfuStreamFor(uuid); } catch(_){} }
|
||||
|
||||
if (attempts >= PEER_MESH_MAX_RETRIES){
|
||||
peerMeshGiveUp.add(uuid);
|
||||
|
|
@ -8213,8 +8092,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">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-07</span><br>
|
||||
md5 <span class="stamp-md5">1e77ac2d270d5b6b8697257a6d7f43cc</span><br>
|
||||
sha256 <span class="stamp-sha">6b76bd2486a0b366599cc8e22c1e11d51072e3bf09df3afed6f0208e5af8b0a9</span><br>
|
||||
md5 <span class="stamp-md5">9e0930862e9720838a0d0928578590f7</span><br>
|
||||
sha256 <span class="stamp-sha">680c406db618bcc53fc029f073347c4b921af2dde9a97563ea308c23ea8451d3</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>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue