zebra-spaces: fresh-stream wrap on mic ontrack + mute-gate mesh swap (kill fedora-chrome silence)

Fox 2026-06-07 telemetry on blanka-chrome ruled out the sink-routing
hypothesis from 467146a:

  audio via AudioContext 085b target=0.5s ctxState=running sink=default
  mesh stream swapped into worklet for 085b
  ...
  aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s

50 pps stereo Opus arriving on the SFU sub PC, sink routed to the
picked device, but jbuf=? (jitterBufferEmittedCount=0) + level=0 — the
native decoder consumes nothing because no MediaStreamSource is bound
to the SFU receiver's track. fxhp-phone same room same window decodes
fine (jbuf=0.49s level=0.001), so the SFU is healthy and the defect is
per-client wiring.

Two coupled defects:

(A) tracks=0 race in handleRemoteSfuTrack mic branch. Chrome can fire
ontrack with ev.streams[0] still empty at handler-time (the live track
arrives a microtask later, or MSID-supplant merges new+dead tracks).
Caching ev.streams[0] then handing it to attachSfuTrack drops the
listener into the silent <audio> fallback — telemetry: "sfu attach
b1a7 fresh=1 tracks=0" + "meter for b1a7: MediaStream has no audio
track". Screen/camera/game already wrap ev.track in a fresh
MediaStream; mic now does the same. Single track, guaranteed live,
every time.

(B) Mesh ontrack unconditionally swaps the worklet's source to the
mesh stream — even when the mesh track is still muted (no RTP). The
SFU receiver becomes orphaned (no decoder), and if the mesh track
never unmutes the listener hears silence with no automatic SFU
restore. Gate the swap on track-not-muted: swap immediately if mesh
is already flowing, otherwise wait for 'unmute'. Add a mute-watchdog
that swaps back to the cached SFU stream after MESH_MUTE_WINDOW_MS
of mesh silence — same shape as the existing
connectionState=='failed' restore path, but driven by track-level
mute instead of PC-level failure.

Hard refresh + leave/enter worked because the fresh negotiation
delivered the mesh track already unmuted, so the eager swap landed
on a live source. Now the same swap waits for live source instead of
hoping for one.

Tests:
  - handleRemoteSfuTrack: ev.streams[0] empty but ev.track live →
    fresh MediaStream wrap rescues the attach (chain built, audible
    path verified)

Ticket 0001 updated with the telemetry comparison and the new
diagnosis. Mesh-mute-watchdog has no dedicated test yet (the mesh
ontrack lives in connectToPeer which the listener test harness
doesn't extract) — pin in multi-peer-mesh.test.js next pass.
This commit is contained in:
Russell Ballestrini 2026-06-07 11:33:57 -04:00
parent 1ed5263fbc
commit caa0548113
No known key found for this signature in database
7 changed files with 224 additions and 48 deletions

View file

@ -775,6 +775,40 @@ test('handleRemoteSfuTrack: track-ended drops the cache entry (so next peer-join
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on track ended');
});
test('handleRemoteSfuTrack: ev.streams[0] empty but ev.track live → fresh MediaStream wrap rescues the attach (kills the tracks=0 race)', () => {
/* Chrome can fire ontrack where ev.streams[0] is the supplant-merged
* stream object that does NOT yet contain ev.track at handler-time
* (the track is added a microtask later). Without the fresh-stream
* wrap, attachAudioStreamViaWorklet sees liveAudio.length === 0 and
* rejects → silent <audio> fallback → telemetry shows
* `fresh=1 tracks=0` + `meter ... MediaStream has no audio track`,
* and the SFU receiver is never wired to a decoder (jbuf=?, level=0).
* Fox 2026-06-07 blanka-chrome telemetry pinned exactly this shape.
*
* Mic ontrack must mirror screen/camera/game: build
* `new MediaStream([ev.track])` and use THAT for both the cache and
* attachSfuTrack. Then the worklet path always sees the live track. */
const b = makeBrowser('listener');
addMember(b, UUID_A, PUB_A);
/* simulate the race: real live track, but ev.streams[0] still empty */
const emptyStream = new FakeMediaStream([]);
emptyStream.id = PUB_A.slice(0, 16);
const liveTrack = makeAudioTrack();
b.api.handleRemoteSfuTrack({
streams: [emptyStream],
track: liveTrack,
receiver: { playoutDelayHint: 0, jitterBufferTarget: 0 },
});
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache primed under full pubHex');
const cached = b.api.sfuStreamsByPubHex.get(PUB_A);
truthy(cached !== emptyStream, 'cached stream is FRESH, not the empty ev.streams[0]');
eq(cached.getAudioTracks().length, 1, 'cached stream contains exactly the live track');
eq(cached.getAudioTracks()[0], liveTrack, 'cached track === ev.track');
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built — attach did NOT bail out on tracks=0');
const node = b.api.listenerAudioNodes.get(UUID_A);
truthy(reachable(node.src, b.ctx.destination), 'audible path through worklet');
});
test('handleRemoteSfuTrack: own publish echo is skipped (sid prefix matches myKeys.pubHex)', () => {
const b = makeBrowser('listener');
/* myKeys.pubHex = 'f'*64 in our stub */