diff --git a/test/listener-audio-attach.test.js b/test/listener-audio-attach.test.js index 1f57e5b..2cdd7ac 100644 --- a/test/listener-audio-attach.test.js +++ b/test/listener-audio-attach.test.js @@ -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', () => { diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 8ba8adc..d0d8787 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -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');