Commit graph

243 commits

Author SHA1 Message Date
05064a487e
zebra-spaces: Phase 3 — adaptive time-stretching worklet (Double Dragon final form)
Replaces Phase 2's hard SFU↔HTTP switching with continuous
playback-rate adaptation in the worklet itself. Speakers / cohosts /
hosts start at 0.5s conversational latency; when the Double Dragon
controller detects upstream instability it raises the target to 4s
and the worklet TIME-STRETCHES playback (≤8% slowdown) to grow its
actual buffer toward the new target — no silence, no clicks, just a
brief ≤1 semitone pitch shift during the adaptation window. When
clean stats return for 30+ seconds, target shrinks back, worklet
speeds up (≤8%), buffer drains naturally.

Listeners are explicitly locked at rate=1.0 via a new 'lock_rate'
port message — music must not be resampled.

Worklet additions:
- cursor (fractional input-sample index)
- stretchFactor / targetStretch (1.0 default; ±8% range cap)
- stretchRampPerBlock = 0.0002 (about 7.5%/sec ramp)
- adaptive rate target driven by buffered/targetSamples ratio
- linear interpolation between adjacent samples for sub-sample reads

Controller changes:
- ddSetTargetForPub(pubHex, sec) posts retarget to the publisher's
  worklet
- Auto path now GROWS target on instability, SHRINKS on sustained
  clean — no startStream/stopStream involvement
- DD_BASE_TARGET_SEC = 0.5 (conversational)
- DD_MAX_TARGET_SEC  = 4.0 (full wiggle cushion)
- Manual HTTP toggle still respected (state goes manual-on/off,
  controller hands off)

Trade-offs documented:
- Linear interpolation (not WSOLA grain processing). Brief pitch
  shift during adaptation but minimal artifacts at ≤8% rate change.
- Phase 4 (pitch-preserving WSOLA via grain processing) deferred —
  call when current shift is audible enough to upgrade.

End user behavior:
- Calm room: speaker latency stays at 0.5s, conversation feels natural
- Host wiggles: speaker's buffer grows over ~10s to 4s, listener hears
  a brief slowdown, then continuous cushion absorbs subsequent wiggles
- After 30s of clean: buffer shrinks back over ~10s, latency returns
2026-06-04 19:46:23 -04:00
1d74af874f
zebra-spaces: Phase 2 Double Dragon auto-engage controller
Per-publisher health monitor runs once per 5s telemetry tick.
Samples the active audio receiver's lost/jitter; transitions a
per-pubHex state machine between 'mesh' (worklet) and 'http'
(HTTP /stream).

State machine:
  mesh         (default) — listening via worklet (mesh source)
  http         — auto-engaged HTTP /stream; worklet muted by
                 existing startStream gain-ramp
  manual-on    — user toggled HTTP manually; controller hands off
  manual-off   — user toggled HTTP off; controller hands off

Auto transitions (manual states never auto-flip):
  mesh → http: 2 consecutive samples show loss > 2/s OR jitter > 30ms
  http → mesh: 6 consecutive clean samples (~30s)

Manual state set on toggleStreamFor — clicking the per-speaker
toggle records the user's intent; controller respects it until
they leave + re-enter.

Auto engagement reuses the existing infrastructure:
- startStream(uuid, pubHex) sets streamMode + ramps worklet to 0
  + locks lip-sync override to HTTP_STREAM_DELAY_SEC
- stopStream(uuid) clears the override + ramps worklet back

Audible switch: ~2.5s time-jump per direction (mesh delay vs HTTP
delay). Listener briefly hears past content on mesh→http or future
content on http→mesh. Phase 3 (sample-aligned dual-decode via
cross-correlation, fed through a 2-input worklet) eliminates the
jump but needs a dedicated session.

Telemetry will show:
- "double-dragon pub=XXXX AUTO-ENGAGE HTTP (loss/s=N jitter=Nms)"
- "double-dragon pub=XXXX AUTO-DISENGAGE (clean N samples)"
- "double-dragon pub=XXXX → manual-on (manual)" on user toggle
2026-06-04 18:26:17 -04:00
e0bf064629
zebra-spaces: speaker buffer 4s + HTTP /stream lip-sync override
Two changes:

1. SPEAKER_PLAYOUT_DELAY_SEC: 0.5 → 4.0.
   Cohost on Fedora Chrome was glitching during host's X11 wiggles
   because 0.5s mesh cushion was shorter than typical wiggle stalls.
   Fox 2026-06-04: "we needed 4 secs before for the wiggle. it was at
   least 4 secs for 20 sec wiggles; we didn't test less." Speaker
   conversation latency goes from ~500ms to ~4s round-trip — accepted
   trade for wiggle-immunity. Future twin-stream double-dragon
   controller (mesh + HTTP /stream parallel, adaptive switching based
   on observed loss/jitter + hardware/network/feed performance) will
   reclaim conversational latency while keeping the glitch floor.

2. HTTP /stream lip-sync override.
   When the per-speaker stream toggle is ON for a publisher, audio
   comes from an <audio> element with its own deep buffer (~2.5s),
   not the worklet. Previously the lip-sync algorithm kept driving
   from the (silent) worklet's measurement → video target stayed at
   the worklet's ~4s while audio actually was ~2.5s → 1.5s mouth-
   leads-voice. Now startStream sets httpLipSyncOverride[pubHex] =
   HTTP_STREAM_DELAY_SEC and forces an immediate retarget via
   applyLipSyncForPub. stopStream clears the override and the next
   worklet 'buffered' message restores worklet-driven targeting.

Constants kept conservative — HTTP delay is hardcoded at 2.5s; could
be made dynamic by reading audio.buffered.end(0) - audio.currentTime
plus an Ogg-granule offset, but the spread is small relative to the
80ms perception threshold.

Fox 2026-06-04 framing: "think of it like a CD that is literally
being skipped in a physical disc man and solve it with two lasers
one moving fast mesh as fast as possible and one for the broadcast
also as fast as possible dynamic based on not hardware fingerprint
but hardware performance and network performance and feed performance
double headed hydra! double dragon!" — Phase 2 lands the auto-engage
controller per that spec.
2026-06-04 18:21:49 -04:00
c3ff58ca39
zebra-spaces: route mesh audio through the worklet — same cushion as SFU, no glitches on host wiggle
Fox 2026-06-04: "the cohost on fedora chrome has the wiggle issue when host
is messing with tabs is there any way to sync the other stream and recover
the glitches assuming it is still buffering where the low-latency version
is glitched, this means we need to delay more than what we are or some
other trick mixed in halp..." (plus: "the video would need to be slid
depending on the mode to keep it in sync. complicated but possible.")

Before: mesh audio bypassed the worklet — went straight to an <audio>
element with the native receiver's ~50ms buffer. The SFU worklet path
had 0.5s of cushion that absorbed 200ms host stalls; mesh did not, so
the same wiggle that was inaudible on SFU was clicky on mesh.

After: ONE worklet per remote uuid, source swappable in-place via the
new setWorkletStream(uuid, newStream). Both the SFU stream (cached in
sfuStreamsByPubHex) and the mesh stream contain the same publisher's
content at slightly different network delays — so disconnecting the
old source and connecting a new one to the same worklet is seamless
(the queue's 0.5s of already-buffered samples covers the transition
while the new source fills it).

Wiring:
- mesh ontrack: setWorkletStream(uuid, meshStream). Fallback to a
  fresh attachAudioStreamViaWorklet if no worklet existed (rare —
  only when AudioContext failed at SFU attach).
- mesh connectionState='failed': setWorkletStream(uuid, sfuCachedStream)
  + registerLipSyncAudio(pubHex, uuid, sfuReceiver). attachCachedSfuStreamFor
  remains as the no-worklet fallback.
- HTTP /stream toggle still uses rampWorkletGain (separate <audio>
  element path).

Lip-sync receiver rebinding ("video slid depending on the mode"):
- New sfuAudioReceivers map caches the SFU receiver per publisher.
- handleRemoteSfuTrack mic path: registerLipSyncAudio with SFU receiver
  AND store it in sfuAudioReceivers cache.
- mesh ontrack: registerLipSyncAudio with the MESH receiver — the
  next worklet 'buffered' message will retarget video to mesh's jbuf
  + worklet (≈ 0.55s) instead of SFU's (≈ 1s).
- mesh fail: registerLipSyncAudio back to the cached SFU receiver
  → video re-targets again.

Removes the previous gain-ramp hack for SFU↔mesh transitions —
single worklet means no parallel paths, no need to crossfade. The
gain-ramp is still used for HTTP toggle (where there genuinely are
two paths: worklet + HTTP <audio>).

CPU cost: same as before — the SFU sub PC still decodes audio for
every speaker (we just route the decoded stream to the worklet or
not). Net change is "the mesh <audio> element is gone" — small
saving.
2026-06-04 18:09:26 -04:00
8fc5a4c1f8
zebra-spaces: smooth audio source pivot via worklet GainNode ramps
Single mechanism handling three audible-source transitions, all click-
free via Web Audio linearRampToValueAtTime (100ms by default):

1. SFU → mesh (auto, on mesh PC ontrack):
   - Previous: detachListenerStream → hard tear-down of the worklet
   - Now: rampWorkletGain(uuid, 0, 100) keeps the worklet decoding in
     the background; only the GainNode value moves. Free CPU cost (~3%
     on a phone), zero audible click.

2. mesh → SFU (auto, on mesh PC connectionState='failed'):
   - Previous: attachCachedSfuStreamFor re-attached the SFU stream
   - Now: rampWorkletGain(uuid, 1, 100) restores the path that was
     never disconnected. attachCachedSfuStreamFor kept as fallback
     for the <audio>-element case (when AudioContext failed at attach).

3. SFU → HTTP /stream (manual, per-speaker toggle button next to mic
   state); HTTP → SFU on toggle off:
   - Previous: existing mute logic only touched remoteAudio (mesh
     <audio> elements). Listeners use listenerAudioNodes (worklet),
     so toggle-on left the worklet playing AND started HTTP — double
     audio. Fox 2026-06-04: "it was breaking listeners."
   - Now: startStream calls rampWorkletGain(uuid, 0, 100) and mutes
     the mesh element. stopStream reverses both. Per-speaker toggle
     finally works for listeners.

The mesh and HTTP <audio> elements still hard-mute via .muted (cheap,
no Web Audio path for them). Only the SFU worklet path needs the
smooth ramp because it's the one that'd produce a click if cut
mid-sample.

Telemetry will show:
- "mesh audio attached for XXXX — SFU worklet faded out" on mesh ontrack
- existing "stream on for XXXX — DJ mode" on HTTP toggle ON
- no new event on toggle OFF / mesh fail (just the gain ramp)
2026-06-04 18:00:58 -04:00
fd119cf572
zebra-spaces: tear down SFU worklet path when mesh ontrack fires (no more double-audio)
Fox 2026-06-04: "the cohost is hearing two feeds now."

Regression from role-aware audio routing. Pre-refactor: SFU mic and
mesh mic for the same speaker both wrote into the remoteAudio map
(both used <audio> elements), so the second arrival overwrote the
first — one audible path. Post-refactor: SFU mic for speakers /
cohosts / hosts goes through AudioContext + worklet (stored in
listenerAudioNodes), mesh mic still uses <audio> (in remoteAudio).
Different maps, both play, same voice in two different delays =
echo / phasing.

Fix: at the top of mesh pc.ontrack, call detachListenerStream(uuid)
to remove the SFU worklet path. The existing canSpeak-mesh-skip
in handleRemoteSfuTrack already prevents the OPPOSITE race (SFU mic
arriving after mesh is connected).

Logged so we can see the handoff: "mesh audio attached for XXXX —
SFU worklet path detached".
2026-06-04 17:51:55 -04:00
8d18216854
zebra-spaces: lip-sync — dynamic video playoutDelayHint matches audio total delay
Fox 2026-06-04: "lips on the video share are not synced with the audio."

Source of the desync:
- Audio total delay = native_jbuf + worklet_buffered (because the
  AudioWorklet adds its 4s cushion ON TOP of whatever the native
  receiver does)
- Video total delay = native_jbuf only (no worklet downstream)
- Net: video leads audio by up to ~4s on music listeners; by less on
  voice (where native honors the hint, ~3s); audible mismatch in either.

Algorithm: per-publisher dynamic match. Every ~683ms the worklet
posts {cmd:'buffered', seconds: this.buffered/sampleRate}. JS-side
handler refreshes lip-sync for that uuid:
1. Resolve uuid → publisher pubHex
2. Read audio receiver's getStats jbuf (native part)
3. audioTotal = native_jbuf + worklet_buffered
4. Push to per-publisher history (size 5)
5. Compute median (FEC-style: 3-of-5 must agree before lock-in)
6. If |median - lastApplied| > 0.05s, set every video receiver for
   that publisher: playoutDelayHint = median, jitterBufferTarget = median*1000

Hamming-spirit on a control signal per fox's request: median-of-5
rejects single-sample outliers from network jitter or getStats noise.
50ms hysteresis below perception threshold so the video target
doesn't whip on tiny shifts.

Registers:
- registerLipSyncAudio(pubHex, uuid, receiver) — at mic-kind ontrack
- registerLipSyncVideo(pubHex, kind, receiver) — at screen/camera/game ontrack
- Map: pubHex → {audioUuid, audioReceiver, nativeJbufSec, videoReceivers, history, lastApplied}

The role-aware base delay (playoutDelayForRole) is still applied at
attach as a sane initial value; lip-sync then refines per-publisher
within a few seconds.
2026-06-04 17:13:47 -04:00
ba5583776d
zebra-spaces: listener UI sits in "connecting — buffering 4s" until worklet fills
Fox 2026-06-04: "listeners state should be connecting for 4 secs while
the buffer fills, not immediately to connected."

Previous behavior: the moment the call FSM hit joined, the status text
flipped to "connected as listener" — but no audio was actually playing
yet because the AudioWorklet hadn't filled to its 4s target. User saw
"connected" but heard nothing for ~4 seconds. Confusing.

New behavior:
- listener role + buffer not yet filled → "connecting — buffering 4s
  audio…" (warn-colored dot)
- first AudioWorklet started message arrives → "connected as listener"
  (ok dot)

Mechanics:
1. Worklet's process() posts {cmd:'started', targetSeconds} the moment
   started flips true (buffer filled to target). One per worklet per
   fill cycle.
2. JS-side jbuf.port.onmessage listens; calls onWorkletStarted(uuid).
3. onWorkletStarted flips listenerBufferReady=true once (first started
   wins — audio is audible from that point); subsequent worklets'
   started events are no-ops for UI purposes.
4. setListenerStatusAware(role) picks the right string. Replaced every
   "setStatus('connected as '+role)" call site with this helper.
5. resetListenerBufferReady() called on:
   - fresh welcome with role=listener (first join)
   - role-change DEMOTING to listener (prev !== 'listener' && next === 'listener')
   so the next 4s fill cycle has to complete before "connected as
   listener" returns.

Speakers / cohosts / hosts unaffected — their status flips immediately
because their 0.5s buffer fills in half a second; no visible "buffering"
state.
2026-06-04 16:45:05 -04:00
4d48df96e4
zebra-spaces: role-aware playout delay everywhere — speaker 0.5s, listener 4s
Conversation latency for speakers / cohosts / hosts; lean-back cushion
for listeners. Every receiver type updated together so audio + video
stay in sync (the existing memory rule: video must match audio's
playout delay).

Changes:
1. SPEAKER_PLAYOUT_DELAY_SEC = 0.5; playoutDelayForRole(role) — listener
   gets 4s, all others get 0.5s.

2. attachListenerStreamViaAudioContext → attachAudioStreamViaWorklet
   (generalized). Listener wrapper just calls into it with 4s + the
   Media Session hook. Every role now routes SFU mic audio through the
   AudioWorklet + per-role buffer — so a speaker hearing high-bitrate
   stereo Opus music STILL gets a 0.5s cushion that Firefox's native
   jitter buffer would have ignored.

3. attachSfuTrack: speakers / cohosts / hosts route through the worklet
   path with 0.5s buffer; <audio>-element fallback only on
   AudioContext failure.

4. SFU sub PC video + mic native receiver: jitterBufferTarget +
   playoutDelayHint = playoutDelayForRole(myRole). Listener=4s, others=0.5s.

5. Mesh peer receivers: SPEAKER_PLAYOUT_DELAY_SEC always (mesh is
   always peer-to-peer conversation, no role-mixed case).

6. AudioWorklet handles a {cmd:'retarget', targetSeconds} message —
   recomputes targetSamples / maxSamples and shrinks the queue if the
   new cap is smaller. No reconstruction needed across role changes.

7. retargetAllReceivers(role) called from onRoleChanged before mic
   acquisition starts. Walks listenerAudioNodes (worklet) and
   sfuSubPC.getReceivers() (native audio + video) and applies the new
   target. Mesh peers stay at 0.5s unconditionally.

Speakers were previously running the same 4s setting as listeners. The
native buffer was honoring it for voice (ramping to ~3s) which meant
back-and-forth conversation was effectively impossible — they were
hearing each other 3 seconds late and didn't notice because they were
mostly publishing. This brings conversational latency back to ~500ms
while keeping the listener cushion intact.

No new SDP / signaling — all changes are receiver-side at attach. Role
transition refreshes targets on the existing PC without a renegotiation.
2026-06-04 15:58:59 -04:00
fd2d927b5e
zebra-spaces: worklet sticky-started — tolerate brief drains, don't re-buffer 4s on every hiccup
Fox 2026-06-04: "delay does seem to be about 4 secs but still very chappy."
Buffer IS holding 4s and emitting — that part works — but every transient
empty-queue tick (a single 2.67 ms drain) was setting started=false,
which forced a full 4-second re-fill before emit resumed. So a 50 ms
network jitter on the upstream caused a 4 s silence on the listener.
That's the chop.

Fix: track consecutive empty-queue blocks. Only re-arm (started=false)
after rearmThresholdBlocks (100 = ~267 ms) of sustained silence.
Brief drains emit silence-fill but keep started=true so playback
resumes the instant new samples arrive. Listener hears at most ~267 ms
of dead air on each drain — almost certainly Opus PLC will mask far
shorter ones.

Long outages (>267 ms with no samples) still re-buffer to 4 s — that
case isn't this bug, it's a real upstream death where a fresh
cushion is correct.
2026-06-04 15:34:56 -04:00
6302e9978a
zebra-spaces: AudioWorklet manual jitter buffer for listener role (4s)
Browser-native jitterBufferTarget didn't help on the music stream —
Firefox Android holds it at 0.06–0.21s on a high-bitrate stereo Opus
receiver while honoring 4s on voice and video receivers on the same
PC. Per-codec implementation gap in the receiver-side jitter buffer.

This adds a userland buffer in Web Audio. The listener path already
ran through AudioContext (source → gain → destination); now an
AudioWorkletNode sits between source and gain, queues incoming
128-sample blocks until targetSamples (4 × sampleRate) have arrived,
then emits with a constant delay. Bounded at maxSamples (6 ×
sampleRate) so clock drift can't grow the queue unbounded. If the
queue fully drains, the buffer re-arms — a hiccup doesn't lock us
silent.

Worklet code lives inline as a Blob URL (single-file app: no
separate JS file shipped). loadJitterWorklet is fire-and-forget on
first attach; existing direct-connected streams get swapped through
the buffer the moment the worklet module finishes loading. Fallback
on worklet creation failure: existing source → gain path stays live.

Speaker / cohost / host paths untouched — they need conversational
latency, can't sit on a 4s cushion.

Listener role test plan: rejoin, watch the new "jitter-buffer
installed" log line, observe that the listener is now 4s behind the
host's speech. Wiggle the host (X11) — listeners should hear no
disruption while the buffer is full.
2026-06-04 15:26:07 -04:00
1e4f0fa23d
zebra-spaces: jitterBufferTarget enforces 4s buffer (hint isn't honored on FF Android)
Fox 2026-06-04 — definitive observation: "the phone as a listener doesn't
seem to be 4 secs behind ever." playoutDelayHint is a HINT the browser
is free to ignore; Firefox Android apparently does. The phone's actual
buffer was near-zero — so every host-side encoder stall propagated
audibly to listeners with no cushion.

Three changes:

1. Set RTCRtpReceiver.jitterBufferTarget = 4000 (ms) alongside
   playoutDelayHint. jitterBufferTarget is NOT a hint — it's a target
   the receiver must aim for. Chromium 113+ (May 2023), Firefox 124+
   (2024). Older browsers silently ignore the assignment (try/catch).

2. Applied at all THREE attach sites:
   - SFU video receiver (screen/camera/game)
   - SFU mic receiver
   - mesh peer mic receiver

3. Add `jbuf=Xs` to telemetry, computed from inbound-rtp
   jitterBufferDelay / jitterBufferEmittedCount. This is the ACTUAL
   average buffer depth — we can now see whether the receiver is
   holding ~4s or 0.05s. If jbuf stays small after this deploy, the
   browser is ignoring the target too and we need a different
   approach (AudioWorklet manual buffering, or move to HTTP-pull DJ
   path for listeners).

Existing playoutDelayHint setting kept for older browsers that honor
it but don't yet support jitterBufferTarget.
2026-06-04 15:10:38 -04:00
e3f91b1e55
zebra-spaces: split outbound-rtp telemetry per kind (.aud + .vid)
Previous loop picked the max-packets outbound-rtp per PC, which always
collapsed onto the video track on screen/camera PCs — hiding what
screen-audio was doing during the wiggle test entirely.

Now iterates ALL outbound-rtp entries on every publisher PC and emits
one line per kind: scr.send.aud, scr.send.vid, cam.send.aud (when the
camera mic is published), cam.send.vid, mic.send.aud, game.send.*.
remote-inbound-rtp is paired back by ssrc.

Lets us answer the key diagnostic question: when Firefox's
getDisplayMedia video capture stalls under X11 wiggle, does the audio
track from the same MediaStream stall in lockstep (Firefox couples
audio + video producers internally) or stay flowing (decoupled)? If
decoupled, an app-layer fix (route audio + video to separate
RTCPeerConnections) would work. If coupled, the fix has to be either
OS-level (PulseAudio loopback to mic) or upstream in Firefox.
2026-06-04 14:53:39 -04:00
763ba181be
zebra-spaces: audio priority='high', video priority='low' at the RtpSender
When the sender's egress queue gets contended, audio wins. Fox 2026-06-04:
wiggling a terminal window (X11) caused 1-5s audio cutouts on all mesh
peers AND listeners — even with playoutDelayHint=4.0 on subs. Theory
(fox): "i might be the clients video trying to compete with the audio,
the wiggled updates" — exactly that. X11 window wiggling produced a
storm of dirty regions for screen-share; the encoder spiked into a big
keyframe burst; audio packets queued behind that burst arrived late;
listener's playoutDelayHint buffer drained.

Fix: RTCRtpEncodingParameters.priority + networkPriority. Set audio to
'high' (mic + any DJ audio track), video to 'low' (screen, camera).
Both Firefox and Chromium honor these for the local egress queue —
audio packets jump ahead of video bursts.

Applied at:
- setSenderBitrate (mic publisher) — high
- setSenderMaxBitrate (screen audio, camera audio) — high
- setSenderMaxBitrate (screen video, camera video) — low

No new param, no SDP renegotiation needed — setParameters() applies
immediately to the existing PC. Existing call sites all flow through
these two helpers, so the change reaches every publisher.

Pair with publisher-side outbound-rtp telemetry (836a036) — gap on
aud.send during a wiggle is now the metric we measure against.
2026-06-04 14:38:12 -04:00
836a036cfb
zebra-spaces: publisher-side outbound-rtp telemetry — mic/cam/screen/game
Adds aud.send/cam.send/scr.send/game.send lines to the 5s telemetry tick:
- pkt sent, bytes sent, framesEncoded (video)
- gap = now − lastPacketSentTimestamp (wire stall detector)
- rtt + rlost + rjit from remote-inbound-rtp (the SFU's view of us)

Until now we only had inbound stats on the listener path. A publisher
stall — e.g. X11 compositor blocking Firefox during a window wiggle —
was invisible at the wire; we could only infer it from downstream
listener loss/silence. Fox 2026-06-04: "wiggling a terminal window
causes 1-5s cutout on all mesh devices and listeners (4s buffer should
absorb that)." Hypothesis is a publisher-side audio-thread stall;
this lets us confirm by watching gap >> 0.02s on aud.send during a
wiggle.

No new IPC, no new dispatch — same getStats() call shape already in
use for inbound, just iterating the publisher PCs (sfuPubPC,
sfuCameraPC, sfuScreenPC, sfuGamePC) and picking outbound-rtp +
remote-inbound-rtp pairs.
2026-06-04 14:37:03 -04:00
f45b22054e
zebra-spaces: pick up epoch on peer-booted + role-change → release mod queue gate
The mod-action serializer (runModSerial) was waiting for case 'state' to
release the next sign — but no 'state' broadcast follows a successful
kick/ban/promote. The server bumps rm.epoch and emits the action-specific
event (peer-booted / role-change) carrying the new epoch. The client was
already updating roomEpoch in role-change but never released the queue
gate, and the peer-booted handler did neither — so kick/ban always burned
the 1500ms fallback timeout AND signed the next action with the stale
epoch (signal bounces it with "stale epoch").

Fox 2026-06-04: "couldn't kick until leaving as host" — leaving + welcome
was the only thing that refreshed roomEpoch.

Pair with signal-side fix that moves rm.epoch++ before the peer-booted
broadcast and includes "epoch" in the payload.
2026-06-04 14:22:27 -04:00
7f9d8273c9
zebra-spaces: MuteFSM — lift muted state to a finite state machine
`muted` was a bare global mutated from 8+ sites (btn-mute click, peer-force-muted,
role promote/demote, self-listener enable/disable, leave handler, sessionStorage
restore). Each call site also had to remember to call applyMuteState() and
sendMicState(). Drift was inevitable — a recent regression where self-listener
toggle muted the wrong direction came straight from this implicit-state pile.

New shape:
- muteSpec: states { on, off }, events { TOGGLE, FORCE_MUTE, AUTO_MUTE,
  AUTO_UNMUTE, RESTORE_MUTED, RESTORE_UNMUTED, ROLE_PROMOTED }, ctx.source
  tracks who muted us ('self', 'mod', 'self-listener').
- `muted` is now a getter over roomMachines.mute.state — single source of truth.
- One observer drives applyMuteState + sendMicState + sessionStorage persistence
  + log line on every transition.
- Imperative call sites only dispatch events; they no longer touch side effects.

Tests: test/zebra-fsm.test.js harness updated to extract muteSpec (same brace-
matched-regex pattern as selfListenerSpec). 88/0 passing. MuteFSM-specific
transition tests are next.

Pattern is now load-bearing — call/publish/subscribe/remote-tile/self-listener/
mute all live as FSMs with the same shape.
2026-06-04 14:13:24 -04:00
10ccea9cb6
zebra-spaces: applyPublishStateUI — share/stop button visibility from publishSpec FSM
Builds on 07d0af9 (publishSpec wired to imperative flow) — adds an
observer that derives btn-{screen,camera}-share / btn-{kind}-stop
visibility from the publishSpec state instead of from scattered
classList writes in sfuPublish/sfuUnpublish.

The state→UI mapping:
- off / failed → share button visible, stop hidden
- acquiring / negotiating / live / stopping → stop visible, share hidden
  (stopping is treated as "still sharing" to avoid flicker during
  teardown)

Removed four classList writes (two in sfuPublishScreen+sfuUnpublishScreen,
two in sfuPublishCamera+sfuUnpublishCamera). The buttons now reflect
the FSM rather than whoever last touched them — if the FSM
transitions because of the silent rebuild (sfuRebuildScreenPC) or
the watchPublishPC rebuild path or any future state-driver, the
buttons follow automatically.

Mic has no share/stop button (it's always on for speakers, off for
listeners), so applyPublishStateUI early-returns for that kind.

All 88 fsm + 12 self-listener tests still green.
2026-06-04 13:58:04 -04:00
07d0af91d6
zebra-spaces: wire publishSpec FSMs into the mic/camera/screen lifecycle
The publishSpec FSM (off → acquiring → negotiating → live → stopping
→ off) has existed since the FSM scaffolding landed but was never
actually driven — wireZebraMachines created mic/camera/screen
instances and the imperative sfuPublish* / sfuUnpublish* never sent
events at them. So the spec was documented behavior, not enforced
behavior.

Wired all three publish paths to drive their FSMs in lock-step with
the imperative flow:

- sfuPublish (mic): START → ACQUIRED (with stream) → NEGOTIATED
  (with pc + peerID) → on watchPublishPC rebuild path STOP+DONE
- sfuPublishCamera: same pattern, FAILED+DONE on getUserMedia
  cancel + HTTP errors
- sfuPublishScreen: same pattern, FAILED+DONE on getDisplayMedia
  cancel + HTTP errors
- sfuUnpublish / sfuUnpublishCamera / sfuUnpublishScreen: STOP +
  DONE around the actual teardown

Trace observers attached for all three kinds — every transition
logs as `pub.mic: prev → next [EVENT]` so QA can see the publish
lifecycle in the page log alongside the imperative `sfu publish:
starting` lines.

The imperative state (sfuPubPC / sfuCameraPC / sfuScreenPC) remains
the source of truth for now. The FSM is a parallel view that
future migrations can hang side effects off of (e.g. a single
applyPublishStateUI() observer driving the share-button visibility
the way applyCallStateUI() drives the call chrome).

All 88 zebra-fsm tests still green (no spec changes, only call-site
additions). The pattern fox 2026-06-04 directed: every system as a
state machine, side effects ride observers.
2026-06-04 13:55:44 -04:00
49c65255ee
zebra-spaces: hoist call-state UI into a single FSM-observer — kick/ban/blocked all converge
Fox 2026-06-04 directive: "all systems need state machines." Self-
listener is already an FSM (commit f4dbc5c). Next system: the
top-line connection chrome (entry-row visibility, sec-room reveal,
dot color, leave/mute button visibility, status text, btn-enter
disabled). Previously these were scattered classList + setStatus
writes across welcome, peer-booted (self), btn-leave click, and
handleBlocked — easy to drift, every UI bug fox flagged ("dot still
green after kick", "entry row should be hidden when joined", "leave
button gone after kick") was a different leaf of this implicit
state model.

Wire applyCallStateUI(state, prev, ctx) as a roomMachines.call
observer. Single function, six branches (idle/connecting/joined/
reconnecting/leaving/booted), drives every relevant DOM toggle.

callSpec gains a `bootedAction` context field — 'kick' | 'ban' |
'blocked' | null — set by BOOTED's action so the UI observer can
render the right status ('kicked from this space' vs 'banned from
this space' vs 'blocked from this space') AND decide whether to
re-enable btn-enter (kick: yes, can re-enter; ban/blocked: no).

Call sites updated:
- handleBlocked: now passes { action: 'blocked' } in BOOTED payload
- case 'peer-booted' (self): now passes { action: m.action } so
  kick vs ban propagates to the FSM
- btn-leave click: imperative chrome removed (was a 9-line
  classList chain), replaced by send('LEAVE') + send('DONE') —
  the observer handles the rest
- joinSpace: removed imperative btn-enter.disabled / setStatus
- case 'welcome': removed imperative dot/buttons/sec-room/row-entry
  toggles — observer covers them

Tests added in test/zebra-fsm.test.js (now 88/88):
- BOOTED with action=kick → bootedAction=kick
- BOOTED with action=ban → bootedAction=ban
- BOOTED with action=blocked → bootedAction=blocked
- BOOTED with no action defaults to kick (back-compat)
- BOOTED → ACK → idle clears bootedAction

Future migrations should follow this pattern: add a state field to
the spec, hoist the imperative side effects into a switch in an
observer, leave a comment at the OLD imperative location explaining
the migration so the next reader doesn't reintroduce drift.
2026-06-04 13:49:27 -04:00
b4d3f748fa
zebra-spaces: silent screen-share PC rebuild when stream tracks are still live
Asymmetric recovery between media publishers fixed. Mic + camera use
watchPublishPC for auto-rebuild on PC 'failed' — getUserMedia
constraints don't need a gesture once permission is granted, so the
client just creates a fresh PC and re-publishes. Screen share has
been the odd one out: getDisplayMedia requires a fresh user gesture,
so the previous policy was to tear everything down on PC 'failed' +
show a "tap share screen to re-share" log line.

But — the browser's "you are sharing" indicator typically stays on
across a transient network glitch; the MediaStream tracks remain in
readyState='live'. We don't need a fresh getDisplayMedia call; just
a fresh PC bound to the same stream. New sfuRebuildScreenPC() does
exactly that: tears down the dead PC (nulls sfuScreenPC first to
avoid recursive teardown via onconnectionstatechange), unpublishes
the old peer_id at the SFU, builds a fresh PC against the existing
sfuScreenStream, publishes, re-attaches the same recovery handler.

Asymmetry collapses: screen now auto-rebuilds silently IF the source
is still alive, and only falls back to the manual "share screen"
prompt when the user has actually stopped sharing (browser-native
stop, source window closed, lid closed). Mirror of the sub-PC
self-heal we shipped in 09a4516 — closes the last asymmetry between
publishers (and the last UX wart fox 2026-06-04 flagged on rejoin).
2026-06-04 13:44:31 -04:00
09a451697a
zebra-spaces: rebuild sub PC on remote-driven 'closed' (not just 'failed')
When the SFU closes the listener's sub PC server-side (e.g. via the
wedge-recovery in renegotiateLocked), the browser's
pc.connectionState transitions to 'closed', not 'failed'. The
existing handler only triggered a rebuild on 'failed' and
explicitly returned on 'closed' (under the assumption that 'closed'
== self-teardown). That assumption holds for sfuUnsubscribe (which
nulls sfuSubPC BEFORE pc.close()), but NOT for remote-driven
closes — sfuSubPC === pc is still true, the guard sees
'unexpected close', and we should re-subscribe.

Without this branch the host's sub stayed at sub=none indefinitely
after any server-driven close — every subsequent addPubToSub for
the host went into the void. Fox 2026-06-04 lost cohost camera +
screen share via this exact path after the wedge-recovery falsely
fired on his fresh subscribe (SFU fix 8c5fd65 prevents the false
positive going forward, this fix ensures the page recovers on any
legitimate server close).

Both 'failed' and 'closed' now rebuild via sfuUnsubscribe +
sfuSubscribe, gated on sfuSubPC === pc to catch only remote-driven
state changes.
2026-06-04 13:27:59 -04:00
545ba93557
zebra-spaces: don't truncate uuids in the latency panel
Fox 2026-06-04: 'don't truncate uuids in the latency page'. Mesh
peer rows previously showed `peer abcd…wxyz` (shortHex 4+4 chars)
when no handle was known, or just `peer HANDLE` (uuid hidden) when
it was. Neither is useful for QA — same shortHex prefix can collide
across sessions, and handle alone makes it hard to correlate with
SFU/signal logs.

New format: `peer HANDLE FULL_UUID` when both known, else just
`peer FULL_UUID`. Full 32-char hex always present.
2026-06-04 13:17:14 -04:00
1449f40954
zebra-spaces: hide rendezvous-code + enter button while joined (over-the-shoulder privacy)
Fox 2026-06-04: 'on join the space name and enter button should be
hidden it should only show when disconnected. this prevents people
without the password or link to be able to see it on the screen.'

The rendezvous code is the join secret — anyone reading it off the
host's screen can join the space (or rejoin under a fresh identity
to bypass moderation).

Added id="row-entry" to the existing entry row, then hide it in the
'welcome' case (alongside the existing reveal of sec-room), and
restore it on:
- leave (btn-leave click → cleanup block)
- handleBlocked (banned / signal block)
- peer-booted (self) — so a kicked listener can read the code to
  manually re-enter

Same hidden-utility pattern as the mute/leave buttons. No FSM yet
for the entry-row visibility — its only state is "joined? yes/no",
which is already captured by the call FSM. Future cleanup could
hang the toggle off a callState observer rather than scattered
classList writes.
2026-06-04 13:12:08 -04:00
f4dbc5cc6c
zebra-spaces: formalize self-listener as FSM — pure spec + observer-driven side effects + 12 unit tests
Fox 2026-06-04 directive: every system should be a state machine
with unit + integration + functional test coverage. Implicit-state
defects keep biting (kicked-listener-UI-still-green, two-kick race,
cohost-toggle-kills-phone, audio-wedge-no-recovery). Starting the
formalization with the most-broken-today system: self-listener mode.

Spec (selfListenerSpec):
  off ──ENABLE / TOGGLE──▶ on
  on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off

Sits next to publishSpec, subscribeSpec, callSpec, remoteTileSpec
in zebra-spaces.html. Composed by wireZebraMachines() into
roomMachines.selfListener.

UNMUTE edge encodes fox's invariant: "unmuting should seamlessly
switch them back to the now of the conversation webrtc mesh" — if
the user clicks unmute while on, they implicitly drop back to off.

Side effects (mic mute, streamMode enrolment, remoteAudio muting)
move out of enableSelfListenerMode/disableSelfListenerMode (deleted)
into runSelfListenerEnable / runSelfListenerDisable, called by an
observer attached to the FSM. Pure spec stays Node-testable; the
runtime drives the actual audio plumbing from observed transitions.

Boolean selfListenerMode flag deleted. window.selfListenerMode is
now a getter against the FSM state — single source of truth, no
drift possible. All callers (toggle-button click, mute-unmute,
peer-joined, role-demote, leave) now dispatch FSM events instead
of calling helpers directly.

Tests in test/self-listener-fsm.test.js:
- starts in off
- TOGGLE / ENABLE / DISABLE transitions
- UNMUTE drops to off (the fox-invariant)
- UNMUTE / CLEAR while off is no-op
- DEMOTED drops to off
- CLEAR drops to off
- unknown event refuses
- observer fires on real transitions with prev/state
- runtime observer skips prev===state edges

Existing test/zebra-fsm.test.js updated to extract+expose
selfListenerSpec alongside the other specs (the wireZebraMachines
extract is the integration test).

Makefile gets test-self-listener target + slot in test-all.

All test suites green:
- self-listener:        12 / 12
- zebra-fsm:            83 / 83
- mod-actions:           6 / 6
- web-protocol:       3348 / 3348
- multi-peer-mesh:       8 / 8
- video-track-removal:  18 / 18
2026-06-04 13:10:29 -04:00
d71a37f84a
zebra-spaces: Media Session API on listener attach — OS keeps backgrounded tab alive
Pairs with the signal-server listener alive-ttl exemption (c16f6c1).
When the AudioContext listener path binds a remote stream, declare
a MediaSession to the OS: metadata + playbackState=playing + no-op
play/pause action handlers.

On Android Firefox + iOS Safari this:
- keeps the tab in media-priority mode (less aggressive JS
  throttling, AudioContext stays running)
- surfaces lock-screen / notification-area transport controls
- signals to the OS scheduler not to freeze this tab

Together with the server exemption, mobile listeners can tab away
to email / browser / chat and zebra-spaces audio keeps playing.
2026-06-04 13:01:37 -04:00
cd0b6a74d9
zebra-spaces: kicked-self UI — hollow dot, leave/mute buttons hidden, status updated
Fox 2026-06-04: 'when a listener is kicked the get the green left
states and still have a leave button even though they are out of the
room — should be gone'. The peer-booted (self) branch tore down PCs
+ mic + cleared ACTIVE_CALL_KEY but left the UI looking like a
connected listener — green dot, leave button visible, status still
saying 'connected as listener'.

Fix in the same case 'peer-booted' branch where m.uuid === myUUID:
- dot flips to 'dot warn' (hollow / amber), not 'dot ok' (green)
- status flips to 'kicked from this space' (or 'banned' if action=ban)
- btn-leave hidden + disabled (no room to leave from anymore)
- btn-mute hidden + disabled (no mic to toggle)
- btn-enter re-enabled so the user can manually rejoin if they want
2026-06-04 12:52:30 -04:00
c70c9713ba
zebra-spaces: more telemetry — lastPacketReceivedTimestamp + signal event firehose
Two diagnostic adds for chasing the cohost-toggle-breaks-phone bug:

1. inbound-rtp lastPacketReceivedTimestamp on every audio + video
   receiver, expressed as 'lp=N.Ns ago'. Pkt-delta only shows audio
   stopped on the NEXT tick (5s later); lp pins the EXACT real-time
   moment RTP went silent. 'lp=0.1s' = healthy. 'lp=12s' = receiver
   has been dead for 12s. Lets us tell at a glance whether the
   receiver is starved or just idle.

2. Signal-event firehose. Every received signal message gets a one-
   line log with type + epoch + relevant uuid/role/action. Filters
   out high-rate noise (sdp-from, mic-state, spotlight, state) so the
   page log stays readable. Now when phone audio breaks at 16:29:07,
   we can scroll the phone's page log and see exactly which signal
   events arrived in the seconds leading up.

Format: '« role-change e=12 u=ab12 role=speaker action=…'
The « marker keeps signal events visually distinct from the ·
telemetry ticks and free-form logLines.
2026-06-04 12:39:55 -04:00
794f10a9a5
zebra-spaces: persist mic/speaker by LABEL — survive Firefox pre-permission + Chrome ID rotation
Two regressions in the existing localStorage-restore path that forced
the host to re-pick the monitor input after every hard refresh:

1. Pre-permission Firefox returns deviceId='' for every device in
   enumerateDevices(). refreshMicList ran at page load BEFORE the
   entry-click gesture granted gUM, every match against the saved
   micDeviceId failed, and the `else: micDeviceId = sel.value` clobber
   silently reset the saved selection to ''. After that, getMic()
   picked the default mic instead of the monitor.

   Fix: detect the all-empty case (allEmpty) and bail out — preserve
   the saved selection until a real post-permission enumerate runs.

2. Chrome (and other browsers in some configs) rotates deviceIds
   across browser sessions for privacy. Saved deviceId stops matching
   anything. Old code fell through to the clobber.

   Fix: also save the human-readable label (e.g. "Monitor of WH-
   1000XM5") and fall back to label-match when deviceId doesn't
   resolve. When the label matches, refresh micDeviceId to the
   current session's value + persist the new deviceId.

Two new localStorage keys: MIC_LABEL_KEY, SPK_LABEL_KEY. Change
handlers strip the 'input '/'output ' prefix from the option's
textContent before saving. Same pattern applied to both refreshMic-
List and refreshSpeakerList.

Doesn't help when the saved label also doesn't match any current
device (e.g. headphones unplugged) — sel.value defaults to first
device, same as before. But the common case fox 2026-06-04 hit
("monitor selected, hard refresh, monitor not restored, manual re-
pick needed") is now zero-tap.
2026-06-04 12:36:09 -04:00
165d85759d
zebra-spaces: UA-gated auto-rejoin — mobile pre-fills code + waits for tap
Desktop keeps the zero-click auto-rejoin convenience. Mobile (Android
/ iOS) pre-fills the rendezvous code and surfaces 'click enter to
resume — CODE' as a status, requiring one tap to land in the space.

Why mobile-only block: Firefox Android (and likely iOS Safari) needs
primeAudioOnGesture's silent-oscillator wake to actually start the
AudioContext render thread, and primeAudioOnGesture only runs inside
the btn-enter click handler. An auto-rejoin that bypasses the click
leaves audioCtx suspended → listener MediaStreamSource attaches to a
dead destination → silence. Confirmed today by fox via the
ctxState=suspended telemetry on the phone.

This is the same trade fox originally took in 12df78a but scoped
better: desktop got the auto-rejoin restored in f388d64 because they
don't need the audio gesture, and now mobile pays one tap to keep
listener audio working.
2026-06-04 11:53:17 -04:00
0cba22e135
zebra-spaces: wake AudioContext render thread inside entry gesture via silent oscillator
Phone telemetry post-deploy showed attachListenerStreamViaAudioContext
logging ctxState=suspended despite resume() being called at entry-
click time. Firefox Android requires more than just resume() to keep
the audio render thread alive — the context goes back to suspended
the moment the gesture window closes if nothing is actively playing
through destination.

Fix: inside primeAudioOnGesture, after resume(), play a brief silent
oscillator (50ms, gain=0) through audioCtx.destination. That forces
the render thread to ACTUALLY START rather than just queueing-
pending. After this the context stays running for the session and
every MediaStreamAudioSource attached to destination plays through.

Added 'audioCtx primed state=...' log so we can confirm the state
flipped to 'running' in the entry click.
2026-06-04 11:49:59 -04:00
64094ecf31
zebra-spaces: route listener audio via AudioContext — bypass Firefox Android <audio> autoplay block
Telemetry on the Firefox Android phone listener exposed the real
mechanism: every <audio>.play() rejects with "play method is not
allowed by the user agent", the audio pool exhausts as the wedge-
recovery cycles, and every fresh element produced fails identically.
Pre-blessing the pool via SILENCE_WAV at entry click does NOT carry
over when srcObject is later swapped to a WebRTC MediaStream —
Firefox Android grants <audio>.play() engagement PER ELEMENT PER
SOURCE, and the gesture window expires before the SFU subscribe
round-trip finishes.

AudioContext has a different model: one resume() inside the user
gesture covers every MediaStreamSource subsequently connected to its
destination. No per-source re-engagement needed.

Implementation:
- attachListenerStreamViaAudioContext(uuid, stream): creates a
  MediaStreamSource + GainNode, connects through to
  audioCtx.destination
- detachListenerStream(uuid): disconnects on peer-left
- attachSfuTrack: if myRole === 'listener', try the AudioContext
  path first; on failure (older browsers) falls through to the
  existing <audio> path
- primeAudioOnGesture now CREATES audioCtx if absent (listeners
  never grab a mic so it wouldn't otherwise exist) + resumes it
  inside the gesture
- peer-left handler also calls detachListenerStream

Listener-only because speakers/cohosts/hosts have an active mic +
setSinkId speaker-picker requirements that still want <audio>
elements. Listeners don't pick speaker output devices and don't
talk — Web Audio is the simpler path that survives the mobile
autoplay regime.

Meter unaffected — startMeter creates its own MediaStreamSource for
analysis (multiple sources per stream is allowed in Web Audio).
2026-06-04 11:46:25 -04:00
5314987a6c
zebra-spaces: silent wedge-recovery — fresh pool element on pa=1 ct=0
Replaces the user-visible "Audio paused — tap anywhere to resume"
notice (which I added in 305c60f without remembering fox already
tried and rejected it in 6c9d1b824bfc81: "tap-anywhere added
user-visible noise").

Real mechanism fox identified: hard refresh on the HOST fixed the
phone — because the republish triggers a fresh ontrack on the phone,
which leases a NEW pool element from the pre-blessed pool with
intact autoplay engagement. Mirror that automatically without user
interaction: when the 5s telemetry tick sees a remoteAudio element
wedged (rs>=2 paused at ct=0 with a live srcObject), tear it out,
lease a fresh pool element, rebind the same MediaStream, play().
Silent recovery — no notice, no tap, no user-visible noise.

Doesn't replace the original pool blessing (still primed at entry
click), just provides a continuous self-heal whenever the
srcObject-swap kills engagement on an existing element.
2026-06-04 11:37:15 -04:00
305c60f23b
zebra-spaces: state-based autoplay-block detection — catch pa=1 ct=0 case
Telemetry from fxhp-phone proved the bug isn't play()-promise-rejects.
Firefox Android: receiver decodes audio (aud.recv level=0.449 in the
stats) but the <audio> element stays pa=1 ct=0.00 forever. play()
returns a resolved promise on the pre-blessed pool element, but
assigning the WebRTC srcObject silently breaks the autoplay grant
without firing an error — flagAudioBlocked's promise-reject hook
never gets called.

Add a state-based trigger inside the existing 5s telemetry tick: if
any audio element has rs>=2 (HAVE_CURRENT_DATA) but is paused at
ct=0, call flagAudioBlocked() to surface the tap-anywhere notice.
Listener role only — speakers have other audio paths that shouldn't
be disturbed by a global click handler.

Re-uses the existing tap-to-resume scaffolding from b8fd50c (single
document-level click listener that fires activateListenerAudio()
inside the gesture and self-removes). One tap on the phone screen
should now recover all paused audio.
2026-06-04 11:30:56 -04:00
4ebf353e2f
zebra-spaces: tap-to-resume for Firefox Android autoplay block + kick clears auto-rejoin
Two fixes pulled from today's QA:

1. activateListenerAudio() existed in the page but was orphaned —
   never called from anywhere after a prior refactor. Wire it back
   via flagAudioBlocked(): when attachSfuTrack's play() promise
   rejects (Firefox Android autoplay block past the entry gesture
   window), set audioPlaybackBlocked=true, show a warn notice "Audio
   paused by browser. Tap anywhere to resume." and arm a single
   document-level click/touchstart listener. First tap fires
   activateListenerAudio() inside the gesture, retries play() on
   every paused element (rtc + stream), clears the notice.

   Telemetry that exposed this: phone showed
   `rtc[3110] rs=4 ns=1 pa=1 ct=0.00`  — element had data, network
   idle, paused, never started playing. No recovery path until the
   user reloaded.

   Scoped — the document-click handler is armed only while the flag
   is true, removed on first tap. Doesn't add noise to working
   sessions (which was why the prior tap-anywhere was reverted).

2. Kick clears sessionStorage[ACTIVE_CALL_KEY]. The confirm() text
   says "they can rejoin" — but that means MANUALLY (type the code,
   click enter), not automatically on a hard-refresh / bfcache
   restore via auto-rejoin. Fox 2026-06-04: "we have auto-join on
   the phone that is kicked just rejoins". Closes that loophole;
   victim can still re-enter manually.
2026-06-04 11:27:33 -04:00
8d14873ed9
zebra-spaces: serialize mod actions — fix two-kick-in-a-row "stale epoch"
Server increments roomEpoch on every successful mod action and the new
value rides back on the next 'state' broadcast. Two kicks fired in
rapid succession both signed with the same epoch N — first succeeds
(server now at N+1), second rejected with "stale epoch" because the
client hasn't received the state-update yet. Repro 2026-06-04: host
kicked two phones, only one was actually evicted; signal log showed
1 AUDIT + 1 /internal/evict + "signal: stale epoch" client-side.

Fix: serialize mod-action sends with a promise that resolves on the
next 'state' broadcast or 1.5s timeout. signBytes() runs AFTER the
wait so the signature uses the freshest known roomEpoch. Applied to
every mod action (invite/grant/promote/demote/mute/kick/ban) for
defense in depth — any pair of mod actions had the same race.

Adds 'mod[label] epoch=N (queue ready)' and 'mod[label] settled
epoch=N+1' breadcrumbs so the page log shows the queue draining in
real time.
2026-06-04 10:44:48 -04:00
a08243a24b
zebra-spaces: real-time telemetry — 5s tick + per-attach breadcrumbs
While in a room every 5 seconds the page log gets a one-liner with:
- sub/pub PC connectionState + iceConnectionState
- mesh peer count, selfListenerMode, streamMode size, muted flag
- inbound-rtp audio: packetsReceived, packetsLost, bytesReceived,
  jitter, audioLevel (from RTCPeerConnection.getStats)
- inbound-rtp video: packetsReceived, packetsLost, framesDecoded
- per remoteAudio + streamAudio <audio> element: readyState,
  networkState, paused, muted, currentTime, MediaError code (and
  src tail for streamAudio)

This lets us tell at a glance whether a "silent" listener is:
- starved (pkt count stuck → RTP not arriving)
- decoded but muted/paused (ct stuck, mu=1 / pa=1)
- network-stalled (ns=3)
- waiting on autoplay (paused=1 but rs=4)

attachSfuTrack now also logs the track's enabled/muted/readyState at
attach time and one-shot listeners on playing/pause/ended/stalled/
error per element. Pair these timelines across devices to find
exactly which leg of the chain broke when audio cuts out.

fox: "way more telemetry NOW". This is that.
2026-06-04 10:38:13 -04:00
6cdf11ffdb
zebra-spaces: self-listener mode includes own stream — solo host can monitor
The previous version skipped uuid === myUUID in the enrolment loop,
so a host alone with listeners (the common solo-DJ case) flipped the
toggle and got silence because there was nobody else to stream from.
Include self in the loop — listeners hear every speaker including
us, so "what listeners hear" must pull our own /stream too. Self has
no remoteAudio entry to mute (self-echo skip in handleRemoteSfuTrack),
so that part is a no-op for the self row.
2026-06-04 10:33:16 -04:00
1e2fbfa102
zebra-spaces: self-listener mode — speakers can switch to the HTTP DJ stream
Speakers/cohosts/hosts get a per-row stream toggle ONLY on their own
row that flips their playback for the entire room from the live
WebRTC mesh to the buffered HTTP Ogg/Opus broadcast tap. Auto-mutes
the user's mic when ON (they'd be 2-4s behind the conversation, can't
talk into the delay). Unmuting flips it OFF, restoring the live mesh.

This gives a non-WebRTC audio path that survives cellular ICE/DTLS
churn — when the mesh dies the HTTP <audio> jitter buffer keeps
serving until the user opts back into live conversation.

Side change: dropped the host-controls-other-rows variant of the
toggle (fox: each role only switches themselves). selfListenerMode
populates streamMode with every audible peer, mutes their WebRTC
remoteAudio, and pulls each via /stream?pub=PUBHEX.

Also adds breadcrumb logging to startStream (loadstart/canplay/stalled/
error code) so the next failed click leaves a trail — earlier sessions
clicked the toggle and zero /stream GETs reached the SFU; we can now
tell on which leg the fetch breaks.
2026-06-04 10:29:27 -04:00
f388d64f3b
Revert "zebra-spaces: auto-rejoin pre-fills code only" — restore auto-join
The one-extra-tap trade was supposed to fix Firefox Android listener
audio by preserving the entry-click gesture. In practice the phone
listener still landed silent on rejoin AND we paid the friction cost
on every tab refresh. Restore the original setTimeout(joinSpace, 100)
so a hard refresh resumes the last space the way every other refresh
in this codebase has.

The Firefox Android autoplay issue is real but lives in the audio
pool / attachSfuTrack path, not at the auto-rejoin boundary.
2026-06-04 10:01:17 -04:00
358a8648f9
zebra-spaces: video playoutDelayHint matches audio — fix lip sync
Audio receivers sit on a 4s jitter buffer (RECV_PLAYOUT_DELAY_SEC) while
video receivers had no playoutDelayHint set, so the picture played out
as fast as RTP arrived — mouse clicks, mouth movement and keystrokes
led the voice by ~4s. Set the same hint on every screen / camera /
game receiver inside attachSfuTrack. Mesh path is audio-only so no
mesh-video branch to touch.

Audio is more vital than video for QoS — video adapts to audio's delay,
never the other way around.
2026-06-04 09:51:36 -04:00
24b42a35bb
zebra-spaces: unmute DJ-stream audio — applyAudioMute() is no-op for non-listeners
startStream() created the <audio> element with muted=true and trusted
applyAudioMute() to unmute it after the user gesture. But that hook
became a listener-only no-op during the recent listener refactor, so
host/speaker self-monitor and host-driven DJ on remote speakers played
silently. Unmute on every startStream() call — the toggle click is
itself a gesture so we no longer need the muted-autoplay workaround.
2026-06-04 09:51:01 -04:00
3bc3edf9ea
zebra-spaces: remove dead audioPath references — fixes leave button
JS error fox spotted via the new unhandledrejection forwarder:

  audioPath is not defined

audioPath was a per-uuid map ('dj' | 'rtc') I added when listeners
auto-enrolled into HTTP DJ mode. The auto-enrol path was reverted
when fox pointed out phones can't autoplay HTTP audio, but four
audioPath.set/.clear references survived in startStream, stopStream,
and the btn-leave click handler. The leave handler hit
audioPath.clear() before the bye send and threw ReferenceError,
abandoning the rest of the cleanup (which is why the leave button
felt half-broken: bye did fire from somewhere earlier in the
handler, but post-leave UI never reset).

Removed every dead audioPath reference. WebRTC unmute is now handled
inline in stopStream where needed. applyAudioMute is the last
defined-but-unused holdover; keeping it since callers of attachSfu-
Track / startStream still reference the no-op via stale comments,
and it's cheap.
2026-06-03 23:01:05 -04:00
7981e9e7ed
zebra-spaces: cache-busting meta on the HTML root
Browser HTML caching has been silently serving listeners old builds
even after deploy — log shows zero new-build markers from fox's
phone or laptop chrome session despite md5 matching on the
served file. Heuristic caching with no Cache-Control header gives
browsers freedom to keep the HTML for hours.

Adding the standard meta cache-bust triplet so the HTML is treated
as no-cache by every browser regardless of Caddy headers. JS / CSS
is inline so this covers the whole page in one go.
2026-06-03 22:44:07 -04:00
6d5a2d4e2e
zebra-spaces: trace leave click + global JS error logging
Fox: leave button still not working. Adding a logLine at the top of
the click handler so we can see whether the click ever reaches it,
and a window.onerror + unhandledrejection forwarder so any silent
JS exception that broke event-handler attachment surfaces in
CLIENT_LOG instead of dying in the browser console.

Next test: tap leave on the phone, then check the proxy log for
either 'leave-button: click fired' or a JS error line — that tells
us if the click is being lost (overlay / CSS) or if the handler
itself is throwing.
2026-06-03 22:41:20 -04:00
12df78a481
zebra-spaces: auto-rejoin pre-fills code only; mute+leave hidden idle
Two regressions from the audio-pool work:

 1. Auto-rejoin was firing joinSpace() with no user activation, so
    the audio pool couldn't be primed and listener phones landed
    silent. The follow-up attempt (global click listener on document
    capture-phase) intercepted the leave button and other UI clicks.
    Now: auto-rejoin pre-fills the rendezvous code and surfaces
    'click enter to resume — <code>' as a status — user clicks enter
    to actually join. One extra tap is the price of working audio.

 2. mute + leave buttons were always visible (just disabled) before
    the user joined a space. Fox: they shouldn't be there when no
    space is active. Both start hidden in the HTML; appear when the
    welcome lands; disappear on leave / WS-tear.
2026-06-03 22:21:26 -04:00
44e05b23db
zebra-spaces: pre-bless audio-element pool on entry click
CLIENT_LOG confirmed the listener-phone autoplay rejection:

  rtc autoplay 72b026b4: The play method is not allowed by the user
    agent or the platform in the current context, possibly because
    the user denied permission.

attachSfuTrack runs several async hops after the entry-button click
(WS join → welcome → onRoleEntered → sfuSubscribe → SFU PC
negotiate → ontrack). Firefox Android's user-activation window has
aged out by then. Speakers dodge this because getUserMedia for the
mic counts as confirmed audio engagement; listeners have no
equivalent.

Fix: primeAudioOnGesture now also pre-creates a pool of 16 <audio>
elements and calls play() on each inside the entry-button gesture.
Firefox Android grants autoplay PER ELEMENT and the engagement bit
persists on the element after the click activation expires.
attachSfuTrack now leases from the pool (leaseAudioElement) instead
of creating fresh — the leased element is already blessed for
unmuted autoplay, so srcObject + play() succeed without a fresh
gesture. Pool falls back to fresh-element creation on exhaustion.
2026-06-03 22:12:49 -04:00
9eb5b82f7a
zebra-spaces: drop play button for listeners — autoplay WebRTC like speakers
Fox: 'i thought you removed the streaming mode and the play button to
autoplay webrtc.' Right — the play button was meant to come out too.
WebRTC playback for listeners is now identical to the speaker inbound
path: attachSfuTrack creates an autoplay=true <audio>, sets srcObject,
calls play() once, logs a rejection if it happens. No second-gesture
UI, no listenerOutputMuted toggle, no swap-fresh trick.

The mute button stays disabled for listeners (no mic to mute) — the
same disabled state speakers see before they have a mic. Speaker
mic-mute path is unchanged.
2026-06-03 22:03:56 -04:00
901bab177b
zebra-spaces: revert auto-DJ for listeners, restore WebRTC playback
Fox: 'no we regressed we have never gotten the stream mode to work
for phones only time phone has worked is falling back to webrtc which
is what we are using for speakers not DJ mode... :( listeners.'

The auto-DJ + swap-fresh-<audio> + muted-on-create combo broke the
one path that actually worked: WebRTC playback for listener phones.
Reverting to the original attachSfuTrack flow (autoplay=true,
playsInline, .play() with .catch logging) so WebRTC audio comes up
the same way it did for speakers all along.

DJ mode is intentionally NOT auto-enrolled for listeners — Firefox
Android refuses autoplay on every fresh <audio src=URL> and the
entry-button gesture has aged out by the time the SFU lazy-inits
its Ogg writer. The DJ stream toggle stays on speaker rows for the
host's room-wide control.

btn-mute as listener now just calls activateListenerAudio() which
re-fires .play() on every existing remoteAudio element inside the
click gesture. If attachSfuTrack's initial play() got swallowed,
this gesture lands the audio.
2026-06-03 21:42:18 -04:00
1de3f2c1bc
zebra-spaces: swap fresh <audio> in listener click — Firefox Android fix
CLIENT_LOG made the failure mode unambiguous: after the play button
tap, the WebRTC audio elements report paused=false rs=4 muted=false
srcObject=true — the browser believes it is playing — but Firefox
Android emits zero output. Same shape as the camera autoplay-block
we fixed earlier with swapFreshVideoElement: an element created
muted=true and later unmuted is internally pinned to silent.

Fix mirrors that pattern. Inside the listener's btn-mute click
(real user gesture), every <audio> in remoteAudio / streamAudio
is replaced with a freshly-built unmuted element, the same
srcObject (for WebRTC) or src URL (for DJ stream) is reattached,
and play() fires inside the gesture context. The fresh element
has never been muted so the internal routing is clean.
2026-06-03 21:26:33 -04:00