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

@ -88,13 +88,91 @@ telemetry session shows which sink the worklet is routed to.
- `audioCtx without setSinkId support (older browser) does not throw —
silent fallback to default`
## 2026-06-07 telemetry — sink hypothesis ruled out
Fresh `/var/log/zebra-spaces-signal.log` capture (00:20:01 — fxhp host
publishing real music, blanka-chrome speaker):
```
audio via AudioContext 085b target=0.5s ctxState=running sink=default
mesh stream swapped into worklet for 085b — buffer cushion now applies to mesh too
...
aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
```
Comparison — fxhp-phone in the same room, same window, same publisher:
```
aud.recv pkt=11161 lost=1 bytes=7154201 jitter=0.0090 level=0.001 jbuf=0.49s lp=0.0s
```
- sink IS routed (deployed fix `467146a` confirmed live — later capture
shows `sink=c38572ec…` = the user-picked deviceId, NOT default).
- 50 pps RTP, 9.1 MB of stereo Opus arriving on blanka-chrome's SFU sub PC.
- `jbuf=?` = `jitterBufferEmittedCount === 0` = receiver **decodes
nothing**. There is no consumer wired to the SFU audio receiver.
- fxhp-phone on the same SFU stream has `jbuf=0.49s` + `level=0.001`
(native decoder running) — so the SFU is fine, the problem is per-
client wiring.
So: **sink routing was not the bug, or was only part of it.** With the
sink fix applied, fedora chrome still hears silence.
## Real root cause
Two coupled defects, both visible in `handleRemoteSfuTrack` (line 4700)
and the mesh `pc.ontrack` (line 6693):
**(A) `tracks=0` race at mic ontrack.** Chrome can fire `ontrack` where
`ev.streams[0]` exists but has 0 tracks at handler-time (the track is
added a microtask later, or MSID-supplant merges new + dead tracks).
Screen / camera / game already work around this by constructing a fresh
`new MediaStream([ev.track])` and passing that. **Mic does not.** It
caches `ev.streams[0]` directly into `sfuStreamsByPubHex` and hands the
same reference to `attachSfuTrack`. When that fires before the track
shows up on the stream, `attachAudioStreamViaWorklet` rejects with
`liveAudio.length === 0` → falls through to the silent `<audio>` path
→ logs `sfu attach b1a7 fresh=1 tracks=0` + `meter for b1a7: MediaStream
has no audio track` (both verbatim in today's capture).
**(B) Mesh swap orphans the SFU receiver.** Mesh `pc.ontrack` calls
`setWorkletStream(uuid, meshStream)` unconditionally — it does NOT
verify that mesh is actually emitting audio. The worklet's
`MediaStreamSource` is now bound to the mesh track. The SFU receiver
has no consumer, so its decoder doesn't run (matches `jbuf=?`,
`level=0`). If the mesh track is muted or DTX-silent, the listener
hears nothing — and there is no automatic SFU restore until mesh PC
actually transitions to `failed` (line 6739). Mesh that stays
`connected` but silent traps the listener forever.
Hard refresh + leave/enter fixes both: fresh PC negotiation → fresh
ontrack with the live track in the stream → attach succeeds on SFU
before mesh comes up → mesh swap eventually replaces source but by
then SFU receiver is fine and… actually no, this still ends up on
mesh. The reason hard-refresh works is that mesh negotiation is fresh
and the new mesh track arrives in an `unmuted` state because audio is
already flowing. The mesh swap then lands on a real audio source.
## Fix (in progress)
**(A)** In `handleRemoteSfuTrack` mic branch, mirror the screen/camera
pattern: `const s = new MediaStream([ev.track]); sfuStreamsByPubHex.set(pubHex, s); attachSfuTrack(uuid, s)`.
Guarantees the stream handed to `attachAudioStreamViaWorklet` contains
exactly the live track, every time.
**(B)** In mesh `pc.ontrack`, gate the `setWorkletStream` swap on
`!ev.track.muted` (or `addEventListener('unmute', swap)` if it starts
muted). If the mesh track never unmutes, keep SFU as the worklet
source. Add a watchdog: if a swapped-in mesh track returns to muted
for >5s, swap the worklet back to the cached SFU stream — same
pattern the `connectionState === 'failed'` path already uses at line
6755.
## Status notes
- **2026-06-07 (open):** awaiting fox retest after reload. Next telemetry
should show `sink=<device-id>` (not `default`) on the `audio via
AudioContext` line. If chrome still silent with `sink=<correct-id>`,
hypothesis shifts to PulseAudio per-app routing or chrome's WebAudio
sink negotiation.
- **2026-06-07 (in-progress):** sink hypothesis ruled out by fresh
telemetry; root cause is (A) tracks=0 race + (B) mesh-swap orphan.
Implementing both fixes in this branch.
- **2026-06-06:** the Jun 5 cascade + flushSfuStreams prefix-match fix
(`0ce1339`) helped firefox but not chrome. Telemetry above ruled out
the FSM / attach defects.