Commit graph

275 commits

Author SHA1 Message Date
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
84f591834f
test: pin the mod-action serializer race fix
Six tests covering the kick-race regression fox hit 2026-06-04
("kicked two phones, only one was kicked") and the fix in commit
8d14873:

- single action signs with current epoch and resolves
- second action waits for state-update from the first (the key one
  — fires both actions back-to-back, asserts only the first runs
  before the simulated state-update, then asserts the second's fn
  signs against the FRESH epoch)
- third action queues behind first two and signs cumulatively
- queue does not wedge when state update never arrives (1.5s timeout)
- early state-update releases the gate immediately
- failure inside fn does not poison the queue

Extracts runModSerial / awaitStateUpdate / resolvePendingStateUpdate
from the live web/zebra-spaces.html so the assertions track shipped
code (same pattern as the other web test files). Each test gets a
fresh sandbox so lastModSettled doesn't leak between cases.

Makefile gets a test-mod-actions target plus a slot in test-all.
2026-06-04 10:46:55 -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
c8fc119ea7
zebra-spaces: deeper audio-element telemetry for listener path
Last click showed touched=4 and no play() rejections, yet phone is
silent — so the issue isn't the click handler returning early. It's
that the audio elements themselves are in some not-actually-playing
state we're not catching.

This commit logs per-element: paused, muted, readyState, srcObject
presence (rtc) / src + networkState (dj), and the path tag. Also
ALWAYS attempts play() in the click handler regardless of mute
decision so the user gesture isn't wasted.
2026-06-03 21:22:42 -04:00
8d861e94a6
zebra-spaces: clear DJ-stream state on leave + re-prime audio every entry
Two regressions where the phone went silent across a leave + re-enter
cycle, with no way to recover short of a hard refresh:

1. Leave handler tore down WebRTC + members + tiles but left:
   - streamMode (Set of streaming pubHexes)
   - streamAudio (uuid -> <audio>)
   - audioPath (uuid -> 'dj'|'rtc')
   - autoEnrolRetryTimer running

   On the next entry as listener, autoEnableDjModeForListener would
   skip every pubHex because streamMode.has(pubHex) was still true
   from the prior session → startStream never called → no HTTP /stream
   request → phone falls back to WebRTC and stays there even when the
   host's mic publish is live.

   Fix: stopAutoEnrolRetryLoop + autoDisableDjModeForAll + remove every
   stale streamAudio element from the DOM + clear all three maps as
   part of the leave click handler.

2. primeAudioOnGesture had an audioPrimed=true short-circuit that
   skipped the silent-WAV prime on re-entry. Mobile browsers can
   suspend the audio session on tab background / leave, so a one-shot
   prime doesn't carry across sessions. Drop the gate — every entry
   click re-primes. The primer element self-removes 500ms after
   play() resolves so we don't accumulate hidden <audio> elements.

Tests green (83 fsm + 16 zebra-spaces).
2026-06-03 21:12:51 -04:00
f196714410
zebra-spaces: telemetry on listener audio path
Server log was silent on what the phone was doing after my last
change. Adding logLine() in three places so the next attempt is
diagnoseable from CLIENT_LOG without asking fox to relay phone
screen content:

 - autoEnableDjModeForListener: how many members it saw, how many
   pubHexes it actually added to streamMode.
 - applyAudioMute: how many <audio> elements got their muted state
   touched, per-element play() rejections.
 - btn-mute click as listener: current listenerOutputMuted +
   sizes of remoteAudio / streamAudio / audioPath maps so we know
   if the click is firing and what state it sees.
2026-06-03 21:04:22 -04:00
d9be0e4cda
zebra-spaces: periodic auto-enrol retry covers post-join publish race
If the host's mic publish lands AFTER the listener's initial
autoEnableDjModeForListener pass (which runs 600ms after the listener
sees the peer-joined event), the /stream request 404s with
"no such publisher in room" and onFail removes the pubHex from
streamMode. No event re-triggers the auto-enrol after that — peer-
joined doesn't refire when an already-present member starts publishing.

Add a 4s interval retry loop while in listener mode. autoEnableDj-
ModeForListener is idempotent (skips already-enrolled pubHexes), so
the loop is cheap and only re-attempts the missing ones. Stops
automatically on role transition out of listener.

This was the root cause behind every "no music on phone" report so
far: the SFU logs show /stream 404s when the phone tried, then never
again after the host's mic publish completed. The retry loop closes
the race.

Tests green (83 fsm + 16 zebra-spaces).
2026-06-03 21:02:21 -04:00
334ee82d6b
zebra-spaces: listener mute button doubles as audio-unlock gesture
Fox 2026-06-04, after pulling CLIENT_LOG from the proxy:

  stream for 25cc0aaf7eb0 autoplay blocked: ... — staying on live WebRTC
  autoplay blocked: ... — tap the tile to play

Firefox Android refuses .play() on every <audio> element created
after the entry-button gesture has aged out. Both the HTTP DJ stream
AND the WebRTC fallback were silently dead — the phone heard nothing.

Two-part fix:

 1. Every remoteAudio + streamAudio <audio> element is now created
    with muted=true. Muted autoplay has no gesture requirement on
    any browser — decoding starts the moment the src is set, the
    buffer fills, and audibility is gated entirely by a later
    user gesture.

 2. btn-mute is enabled for listeners (was disabled because there's
    no mic to mute) and re-purposed as the audio-unlock toggle. The
    click is the gesture. applyAudioMute() reads a per-uuid audio-
    Path map ('dj' | 'rtc') and unmutes only the canonical path for
    each peer so the WebRTC duplicate stays silenced while the DJ
    stream plays.

Also picked up while I was in here:
  - startStream onFail clears the pubHex from streamMode so the
    next auto-enrol pass retries.
  - stopStream sets audioPath='rtc' instead of poking remoteAudio
    directly.
2026-06-03 20:57:09 -04:00
24bfc81ac0
zebra-spaces: prime audio on the entry-button click, drop tap-to-resume
The tap-anywhere-to-resume queue was a regression — users saw a "tap
anywhere to start" message and tapping often did nothing, with no
feedback about why. Replace with the obvious-in-hindsight approach:
seize the user gesture from the entry button click itself.

primeAudioOnGesture() plays a 1-frame silent WAV through a hidden
<audio> element inside the entry click handler. Mobile Firefox /
Safari treat that as gesture-driven audio playback and grant the
page audio permission for the session. It also resumes any
suspended AudioContext (meter / chime path) the same way.

By the time auto-enrolment into DJ mode runs (async hops after
entry), the page already has audio permission — dynamic <audio>
elements created later play() without further user interaction.
No "tap anywhere", no queue, no flip-flop on stalled.

Bound to btn-enter click + rdv-code Enter key. Tap-to-resume queue
and pendingAutoplay/installTapResume helpers removed.

Tests green (83 fsm + 16 zebra-spaces).
2026-06-03 20:31:04 -04:00
e1ba62e7ac
zebra-spaces: tap-resume reload + click event + log retry result
When stream autoplay was blocked and the listener tapped to resume,
the retry called a.play() on a <audio> that had already been left in
error state by the browser-aborted prior load. play() on a stuck
element silently no-ops — listener saw the 'tap anywhere' message but
got no audio and no feedback.

Three fixes:
  - call a.load() inside the gesture handler before a.play() to
    restart the fetch from a clean state
  - add 'click' to the listened events alongside pointerdown/
    touchstart — Firefox Android only grants gesture activation on
    click in some configurations
  - log retry result: 'stream resumed after tap' on success,
    'stream tap-retry failed: <msg>' on rejection, so we can see
    what's happening instead of silent failure

Tests green (83 fsm + 16 zebra-spaces).
2026-06-03 20:18:53 -04:00
6c9d1b8ecc
zebra-spaces: tap-anywhere-to-resume on stream autoplay block (mobile)
Mobile Firefox/Safari refuse audio.play() when the gesture activation
window has expired between the entry-button tap and auto-enrolment
into DJ mode. Previously this surfaced as "autoplay blocked" in the
log and the listener was silently stuck on WebRTC.

Now: when play() rejects, the <audio> element goes into a
pendingAutoplay set and one global pointerdown/touchstart listener
on document retries every queued element. Once they all play the
listener self-uninstalls. Idempotent install — adding more elements
during the pending window just enqueues them.

Listener sees: 'stream for <pubhex> — tap anywhere to start (msg)'
in the log; one tap later, every queued stream resumes and the
log fills with 'stream on for ...' lines.

WebRTC stays unmuted during the wait so the listener still hears
the live (possibly choppy) audio — they're not left in silence
between the autoplay block and their tap.
2026-06-03 20:16:11 -04:00
d9198727d9
zebra-spaces: stream-toggle reliability — await sinkId, drop stalled handler, idempotent
Fox saw repeated "stream stalled" and "autoplay blocked: aborted at
user's request" log lines on both auto-enrolled mobile listeners and
host self-monitor clicks. Three coupled causes:

1. applySinkTo() was called sync-fire-and-forget right before setting
   .src and calling .play(). setSinkId() can re-init the media
   pipeline; when it landed during the in-flight load it aborted the
   request — Firefox surfaces that as
   "The fetching process for the media resource was aborted by the
   user agent at the user's request." on the play() promise. We
   wrongly logged that as autoplay-blocked. Fix: await applySinkTo()
   BEFORE assigning .src.

2. The 'stalled' event handler called unmuteWebRtcOnFail every time
   it fired. 'stalled' fires constantly during normal mobile-cellular
   buffering and isn't a terminal failure. Each fire ping-ponged the
   audio path between WebRTC (unmuted) and the still-loading HTTP
   stream. Drop the handler — only 'error' and a rejected play()
   promise indicate real failure.

3. A second toggle-on for the same pubHex (auto-enroll race when
   peer-joined fires during initial enrol) reassigned .src on the
   same <audio> element, aborting the prior load with the same abort
   error. Make startStream idempotent: if the element already has
   our wantUrl and no .error, return immediately.

Plus two cheap mobile-friendly attrs on the dynamic <audio>:
  - preload="auto" so buffering starts before play() (gives the
    user-gesture window time to last past the initial fill)
  - playsInline so iOS/Safari doesn't escalate to a fullscreen player

startStream is now async — callers (toggleStreamFor,
autoEnableDjModeForListener) fire-and-forget the returned promise,
which is fine since all error paths are already caught inside.

Tests green (83 fsm + 16 zebra-spaces).
2026-06-03 20:06:36 -04:00
a3c49f1a28
zebra-spaces: mute button + peer-force-muted handler
Page-side companion to proxy.unturf.com 90a5e96.

 - modMute(uuid): signs + sends {type:"mute",target,epoch,sig}.
 - Mute button on speaker / cohost rows (host always; cohost can
   mute speakers only). Lives in .acts-primary beside the role
   transitions; kick/ban stay on the .acts-removal row below.
 - On peer-force-muted from server:
   - If I'm the target: disable my mic tracks, set muted=true,
     persist to sessionStorage, broadcast new mic-state, show
     "you were muted by X. Click unmute to talk again." Banner —
     the unmute button is the user's own action, not locked.
   - For others: mark the target visually muted right away so
     the roster reflects state without waiting on a mic-state
     broadcast.
2026-06-03 19:51:20 -04:00
e1e2c8daa9
zebra-spaces: stream toggle into its own top-row column (no vertical stretch)
The .stream-toggle CSS class and `strm` grid column were already in
place but the JS still appended the button into .acts-primary —
which lives in the bottom-row mod-actions stack. That meant every
speaker row (including self-monitor on non-mod rows) grew a third
sub-row just to host the single ◉ glyph, adding scroll height.

Render it as a sibling of micEl in the row so it lands in the `strm`
grid column on the TOP row, alongside the mic icon. Single character
text (○ / ◉) keeps the column tight, and the .stream-toggle styles
already shipped pick it up.

For rows that don't qualify (listeners, non-host-viewers on other
speakers), the streamEl is null → not appended → strm column
collapses to its 1.4rem track width. No row-height penalty.

The mod-actions stack stays as-is for actual mod actions (mic-invite,
role transitions, kick, ban). Self rows and non-mod self-monitor rows
no longer carry an empty mod-actions row at all.
2026-06-03 19:38:52 -04:00
55d5e52797
zebra-spaces: mod-action buttons truly horizontal via inline-block
Fox: 'the buttons should be horizontall not vertical on invite and
kick and ban etc.' The previous grid template
`repeat(auto-fit, max-content)` was treated by browsers as a single
column when no fixed sizing function was provided, so every button
ended up on its own row.

Swap to the older inline-block + margin pattern: .acts-primary and
.acts-removal are block-level rows, their <button> children flow
horizontally and naturally wrap to a second line only when the
panel is too narrow to hold them. No flex.
2026-06-03 19:36:10 -04:00
ebcd2575e5
zebra-spaces: defer WebRTC mute until DJ stream actually plays
Fox 2026-06-03: 'the listener phone doesn't hear anything since your last
push.' startStream() was muting the WebRTC <audio> for the speaker BEFORE
calling .play() on the fresh HTTP <audio>. On mobile the fresh <audio>'s
autoplay was often blocked (the entry-button gesture had aged out for
freshly-created elements), so the failure mode was: WebRTC silenced +
HTTP not playing = total silence.

Now WebRTC stays unmuted until the HTTP <audio> fires 'playing'. Any
autoplay/error/stalled path unmutes WebRTC and surfaces the reason in
the log, so we fall back to live WebRTC instead of going dead.

play() promise rejections are also logged now (were silenced) so next
session we can tell exactly which speakers' streams failed to start.
2026-06-03 19:30:50 -04:00
9fd6b06a3f
zebra-spaces: lock listeners into DJ mode — no per-speaker opt-out
Per fox: 'lock them into DJ mode unconditionally.' Stream button is
no longer rendered on listener rows for any other peer. Visibility
rule reverts to {self|host} — same as the post-kick/ban gating —
and listeners get auto-enrolled into the HTTP Ogg/Opus path without
any escape hatch. Host can still flip individual speakers room-wide,
and every speaker still has their self-monitor button.
2026-06-03 19:28:23 -04:00
0cbbc37393
zebra-spaces: default-on DJ mode for listeners + 4s RTC playout buffer
Fox: 'I want the sound to be epic and to fucking choppy ever.'

Two changes that compose:

 1. Default-on DJ mode for listeners. On role landing (or transition
    INTO listener), auto-flip every speaker's playback path off the
    WebRTC subscribe and onto the existing HTTP Ogg/Opus tap from
    the SFU. The browser's <audio> element keeps a deep media buffer
    (~30s in Chrome) that absorbs glitches WebRTC can't. Speakers
    stay on WebRTC for low-latency conversational audio. Listener
    can still opt-out per-speaker via the stream button — gating
    expanded from {self|host} to {self|host|listener}.

    Auto-enrol triggers at:
     - onRoleEntered (initial join as listener)
     - peer-joined (new speaker arrives while we're listening)
     - role-change of OTHER peer (they became speaker)
     - onRoleChanged self speaker → listener
    Auto-disable: onRoleChanged self listener → speaker
    (cannot afford 2s+ buffer when you have to talk back).

 2. RECV_PLAYOUT_DELAY_SEC 2.0 → 4.0 on both mesh and SFU receive
    paths. Conversational latency is now ~4s for speakers; this is
    the cheap-but-real win against chop on the WebRTC path for
    everyone who still uses it. Music/DJ rooms generally don't care
    about 4s — the room's already committed to a stream-mode 2s+.
2026-06-03 19:19:39 -04:00
de5b530427
zebra-spaces: stream-gating + layout + 2s playout + bfcache-safe bye
Four corrections in one pass — all from fox's live-room session
2026-06-03 after the kick/ban + heartbeat ship:

 1. Stream button visibility was too generous. A speaker viewing
    the host's row got a stream button — they shouldn't. New rule:
    SELF row always (self-monitor), OTHER rows only when myRole
    === 'host'. Cohost gets self-only too; lift the gate to
    isMod(myRole) if room-wide cohost stream control is wanted.

 2. .acts-removal's grid-column:1/-1 collapsed the parent auto-fit
    grid down to a single column on a row with both primary +
    removal actions, so EVERY button stacked vertically. Refactor:
    .mod-actions is now a row-stack of sibling groups
    (.acts-primary + .acts-removal), each its own horizontal auto-
    fit grid. Primary still wraps inside itself when the panel is
    narrow; kick + ban are forced to a fresh line by the parent
    grid-auto-rows.

 3. playoutDelayHint bumped 0.7 → 2.0 (RECV_PLAYOUT_DELAY_SEC) on
    both mesh + SFU receive paths. Conversational latency goes up
    but the stream/HTTP-pull DJ mode already commits to ~2s, so
    matching the WebRTC path makes the room consistent. Persistent
    chop survived every SDP-side dial-back fox tried; this is the
    last knob left at the receiver.

 4. pagehide-bye now skips bfcache (event.persisted=true). Without
    this guard, a phone going to lock screen / app-switch /
    minimise fired bye → server evicted SFU PCs → page resumed but
    audio stayed silent. Aliveness of a bfcached page is already
    handled by the server-side aliveTTL (heartbeat stops during
    bfcache → server arms hiccup grace at 45s).
2026-06-03 19:11:30 -04:00
148116ae89
zebra-spaces: kick + ban land on their own row beneath primary mod actions
Destructive controls shouldn't share a row with role-transition buttons —
the muscle-memory misclick where 'ban' sat next to '→ speaker' is exactly
the kind of mod ergonomics fox flagged. Kick + ban now render inside
a nested .acts-removal grid that spans the parent auto-fit row, so they
always wrap to a fresh line below stream / → cohost / → speaker /
→ listener regardless of available width.
2026-06-03 18:58:12 -04:00
8768a54519
zebra-spaces: let mod-actions wrap so stream toggle doesn't overflow
The room-member mod-actions row was hardcoded
  grid-auto-flow: column; grid-auto-columns: max-content;
which forces every button onto one non-wrapping line. After adding
the per-speaker "stream" toggle the strip got long enough
(stream + → cohost + → listener + kick + ban) to overflow the side
panel on common widths — kicked the page into horizontal scroll.

Swap to
  grid-template-columns: repeat(auto-fit, max-content);
so buttons flow onto a second row when the panel is narrow. Still
grid (no flex per project rule). Doesn't change appearance on wide
panels — only kicks in when the button strip wouldn't fit on one line.
2026-06-03 18:48:03 -04:00
c346d94875
zebra-spaces: render "stream" toggle on own row too — DJ self-monitor
Drop the m.uuid !== myUUID guard on the per-speaker stream toggle so
the DJ can preview what listeners are actually hearing of their own
mic. Self-stream URL hits the SFU pulling our own pubkey — the ~2s
delayed playback is the cue we want to confirm the broadcast works.

Title attr warns about feedback risk on open speakers (closed
headphones like WH-1000XM5 are fine — earcups don't bleed into the
headset mic enough to feedback).

Mod buttons (kick/ban/promote/demote) still self-exclude — different
guard, kept intact.
2026-06-03 18:44:06 -04:00
5d365c9ccb
zebra-spaces: per-speaker "stream" toggle — Ogg/Opus HTTP path (DJ mode)
For each speaker (other than self) the member row now carries an
"○ stream" / "◉ stream" button. Toggling it:

  - Mutes the WebRTC remote audio for that uuid (so they don't
    double-play through both paths)
  - Attaches a parallel <audio src="SFU/stream?room=R&pub=PUBHEX">
    that pulls the SFU's new Ogg/Opus broadcast tap as plain HTTP
  - Routes through setSinkId so the speaker-output picker still
    applies

Trade-off: WebRTC path is ~700ms latency but glitch-prone under
network loss / mixed-browser NACK gaps. The HTTP stream path is
~1.5–3s latency but bulletproof — the browser's <audio> jitter
buffer absorbs everything WebRTC can't. The sub.fm-style DJ
listening UX.

State is keyed by pubHex (not uuid) so it survives session-uuid
churn on rejoin. Cleanup hooks into tearPeer so peer-left tears
down the HTTP pull and the SFU stops fanning Ogg pages to a dead
client.

Requires the matching SFU change in proxy.unturf.com main
(GET /zebra-spaces-sfu/stream endpoint).
2026-06-03 18:35:44 -04:00
acab6047aa
zebra-spaces: revert bitrate + drop fullband pins — last new Chrome-path param
Choppy persisted after every other dial-back (NACK off → 510k→320k →
maxptime off). The only remaining new Chrome-publisher param was the
"fullband pin" (maxplaybackrate=48000, sprop-maxcapturerate=48000,
cbr=0). Drop those too and step bitrate back to the known-good 256k.

Chrome publisher's SDP now identical to pre-feature shape:
  stereo=1; sprop-stereo=1; maxaveragebitrate=256000;
  useinbandfec=1; usedtx=0

Kept:
  - Firefox SDP regex fix — Firefox publishers now actually get the
    music-mode params applied (was silently no-op before).
  - UA-gated NACK — Chrome publishers advertise it (responder support);
    Firefox publishers omit (no responder). "audio NACK as sender" log
    confirms which side we're on per session.
  - Speaker output picker (setSinkId UI).

Comment warns against re-adding fullband pins without re-verifying.
2026-06-03 18:12:40 -04:00
aed8484293
zebra-spaces: 320k + drop maxptime + fix Firefox SDP regex no-op
Three coupled changes after still-choppy reports on UA-gated NACK build:

1. 510k → 320k Opus everywhere in music mode. 320k is the transparent
   listening-test threshold (Audible masters at this). 510k is spec
   ceiling but real-world uploads can't sustain it alongside concurrent
   screen 6M + camera 4M without dropping audio packets. Choppy at 510k
   persisted after NACK rollback, so bitrate is the next dial.

2. Drop a=maxptime:120 advertisement. Tried to give Opus larger encode
   windows for cleaner music at same bitrate. Chrome publishers may have
   actually honored it and packed at higher ptime, which the receiver
   side handled poorly (jitter buffer + playoutDelayHint=0.7 weren't
   tuned for >20ms packetization). Default 20ms stays.

3. Fix preferStereoOpus regex no-op on Firefox publishers. Previous
   matcher anchored on minptime=10 which Chrome emits but Firefox does
   not — so for years, Firefox-published mic SDPs went out unmunged:
   no stereo=1, no maxaveragebitrate cap, no fullband pin, no NACK gate.
   Now: scan rtpmap for all opus/48000/2 PTs, update existing fmtp lines
   in place, or insert a fresh fmtp if none exists (Firefox's case).
   This is why fox saw "audio NACK as sender" diagnostic never fire on
   Firefox publish — the function early-returned before reaching it.

Kept: fullband pins (no bandwidth cost), UA-gated NACK code (honest
per-browser advertisement), speaker output picker, 700ms playoutDelayHint.
2026-06-03 18:07:45 -04:00
44babe490a
zebra-spaces: UA-gated audio NACK via RTCRtpSender.getCapabilities
Audio NACK is an asymmetric feature — the SENDER has to respond to
retransmit requests. Chrome implements both directions; Firefox
implements neither for audio. Blanket-advertising NACK in offers from
Firefox publishers caused Chrome receivers to wait for retransmits
that never arrived and skip audibly.

Solution: each browser advertises only what it can back up. Probe via
RTCRtpSender.getCapabilities('audio') and look for nack in Opus's
rtcpFeedback list. Chrome → true → advertise. Firefox → false → omit.
Cached after first call (capabilities are static per UA).

  Chrome → Chrome:   NACK advertised, both sides honor it ✓
  Chrome → Firefox:  NACK advertised, Firefox ignores (no NACK requests) ✓
  Firefox → Chrome:  NACK omitted, Chrome never NACK-waits → no skip ✓
  Firefox → Firefox: NACK omitted, both sides ignore ✓

No LCD across mixed rooms — each side gets the best contract its own
browser can honor. Logs "audio NACK as sender: on|off" once per session
for diagnostic.
2026-06-03 18:02:24 -04:00
873591e87d
zebra-spaces: drop audio NACK — Firefox-publish→Chrome-listen choppy
Audio NACK was advertised in preferStereoOpus(). Chrome receivers will
request retransmits when they see rtcp-fb:nack on the Opus PT, but
Firefox senders never reply (Mozilla never shipped the responder side).
Chrome's jitter buffer waits for recovery that never arrives, then
skips — audibly choppy in mixed Firefox→Chrome rooms.

useinbandfec + 700ms playoutDelayHint already cover the loss case
without protocol churn. Comment block warns future code not to re-add
audio NACK without verifying both sides actually implement it for the
negotiated PT.

Keeps: 510k Opus ceiling, fullband pin, cbr=0, maxptime=120, speaker
picker — none of those are the choppy source.
2026-06-03 17:58:12 -04:00
23a4649b37
zebra-spaces: music-mode polish — 510k Opus, fullband pins, NACK, speaker picker
Codec headroom (Tier 1):
  - Opus 256 kbps → 510 kbps (spec ceiling) at every music-mode site:
    mic publish (SFU + mesh), screen audio, game-share audio. Both
    SDP maxaveragebitrate and RTP-level encodings[0].maxBitrate raised.
  - Pin Opus fullband: maxplaybackrate=48000, sprop-maxcapturerate=48000
    so BWE pressure can't opportunistically narrow to 16/24 kHz.
  - cbr=0 explicit (VBR — Opus only spends what it needs).

Loss resilience (Tier 2):
  - Audio NACK feedback (a=rtcp-fb:<opus_pt> nack) injected after Opus
    rtpmap. Reactive packet recovery, near-zero overhead, ignored by
    browsers that don't honor it.
  - a=maxptime:120 in music mode advertises tolerance for larger frames
    from peers (more encode context = cleaner music at same bitrate).

Playback chain (Tier 3):
  - New speaker output selector (setSinkId) so listeners can route peer
    audio to studio monitors / external DAC. Hidden on Safari (no
    setSinkId on HTMLMediaElement). Persists to localStorage; applied
    to every remote <audio> on creation and on user switch.

Live sessions need leave+enter to pick up the new SDP (per CLAUDE.md —
existing RTCPeerConnections are locked to whatever was negotiated at
creation time).

make stamp updates web/zebra-spaces.html footer date + hashes.
2026-06-03 17:45:50 -04:00