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.
This commit is contained in:
Russell Ballestrini 2026-06-04 16:45:05 -04:00
parent 4d48df96e4
commit ba5583776d
No known key found for this signature in database

View file

@ -1827,7 +1827,12 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
* empty queue tick — that's what made the phone choppy: any
* 2.67ms drain forced a full 4s re-buffer. emptyStreak tracks
* sustained silence and only re-arms after ~267ms. */
if (!this.started && this.buffered >= this.targetSamples) this.started = true;
if (!this.started && this.buffered >= this.targetSamples){
this.started = true;
/* notify JS — listener UI stays in "buffering" state until the
* first started message arrives. */
try { this.port.postMessage({ cmd: 'started', targetSeconds: this.targetSeconds }); } catch(_){}
}
if (this.started && this.queue.length > 0){
const head = this.queue.shift();
this.buffered -= head[0].length;
@ -1881,6 +1886,18 @@ function installJitterBuffer(uuid, node){
},
outputChannelCount: [2],
});
/* worklet → JS: 'started' fires the moment the buffer first fills
* to target. Listener UI sits in "buffering" state until this
* lands — the user sees "connecting (buffering audio)" → 4s
* later → "connected as listener". Multiple worklets each fire;
* we mark the listener buffer ready on the FIRST one (audio is
* audible by then). */
jbuf.port.onmessage = (e) => {
if (e.data && e.data.cmd === 'started'){
logLine('', 'jitter-buffer started uuid='+uuid.slice(0,4)+' target='+(e.data.targetSeconds||target)+'s');
onWorkletStarted(uuid);
}
};
try { node.src.disconnect(node.gain); } catch(_){}
node.src.connect(jbuf).connect(node.gain);
node.jbuf = jbuf;
@ -1890,6 +1907,32 @@ function installJitterBuffer(uuid, node){
}
}
/* Listener buffer-ready gating. Stays false during entry as listener
* (and on demote to listener) until at least one AudioWorklet has
* reported its buffer filled. While false, the call-status text shows
* "connecting — buffering 4s audio…" instead of "connected as
* listener" so the user knows why they don't hear anything yet. */
let listenerBufferReady = false;
function setListenerStatusAware(role){
if (role === 'listener' && !listenerBufferReady){
setStatus('connecting — buffering '+RECV_PLAYOUT_DELAY_SEC+'s audio…', 'warn');
} else if (role){
setStatus('connected as '+role, 'ok');
}
}
function onWorkletStarted(uuid){
if (listenerBufferReady) return;
if (myRole !== 'listener') return;
listenerBufferReady = true;
setListenerStatusAware(myRole);
}
function resetListenerBufferReady(){
/* called on entry-as-listener and on demote-to-listener so the
* buffering wait shows again. The 4s cushion has to fill from
* scratch every time the role transitions into listener. */
listenerBufferReady = false;
}
/* Shared audio attach path. Every role routes through here now so the
* worklet can apply role-appropriate buffer depth uniformly. Listener
* gets a fat 4s cushion (lean-back, latency doesn't matter); speakers
@ -3912,7 +3955,7 @@ function applyCallStateUI(state, prev, ctx){
if (secRoom) secRoom.classList.remove('hidden');
if (btnLeave){ btnLeave.classList.remove('hidden'); btnLeave.disabled = false; }
if (btnMute) btnMute.classList.remove('hidden');
if (status && ctx.role) setStatus('connected as '+ctx.role, 'ok');
if (status && ctx.role) setListenerStatusAware(ctx.role);
break;
}
case 'reconnecting': {
@ -4311,6 +4354,10 @@ async function handleSignal(raw){
* other duplicate-effect noise during reconnect cycles). */
const reentry = !!myUUID && myUUID === m.your_uuid;
myUUID = m.your_uuid; myRole = m.role; roomEpoch = m.epoch;
/* fresh listener entry — buffer hasn't filled yet, status
* should sit in "connecting — buffering" until a worklet
* reports started. */
if (myRole === 'listener' && !reentry) resetListenerBufferReady();
applyState(m.state);
roomMachines.call.send('WELCOME', { uuid: myUUID, role: myRole });
if (reentry){
@ -4395,7 +4442,7 @@ async function handleSignal(raw){
* is rescued — drop the "space closing" status from our top bar */
if (m.role === 'host'){
hostUUID = m.uuid;
setStatus('connected as '+myRole, 'ok');
setListenerStatusAware(myRole);
}
/* a new member may resolve a queued SFU track (e.g. host's rejoin
* race where ontrack fired before peer-joined) */
@ -4530,7 +4577,11 @@ async function handleSignal(raw){
myRole = m.role;
roomMachines.call.send('ROLE_CHANGE', { role: m.role });
logLine('', 'you are now '+m.role+(subjPub?' ['+subjPub+']':'')+(m.by?' by '+idTag(m.by, byPub):''));
setStatus('connected as '+myRole, 'ok');
/* demoted to listener: reset the buffer-ready gate so
* the new 4s cushion has to refill before the UI flips
* back to "connected as listener". */
if (m.role === 'listener' && prev !== 'listener') resetListenerBufferReady();
setListenerStatusAware(myRole);
const byMod = members.get(m.by);
const byTxt = byMod ? ' by '+byMod.handle : '';
/* visible self-notification per role transition */
@ -4683,7 +4734,7 @@ async function handleSignal(raw){
const pub = pubHexFromMsg(m, 'new_host_pubkey');
if (mm){ mm.role = 'host';
if (m.new_host_uuid === myUUID){ myRole = 'host'; setStatus('connected as host','ok'); onRoleChanged('cohost','host'); }
else { setStatus('connected as '+myRole, 'ok'); } /* clears the space-closing warning */
else { setListenerStatusAware(myRole); } /* clears the space-closing warning */
logLine('', idTag(m.new_host_uuid, pub)+' is now host'); }
flushSfuStreams();
renderRoom();
@ -6203,8 +6254,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">7be62e61e3cf7651d652430c128cf977</span><br>
sha256 <span class="stamp-sha">f4f882629980a9f21d34a4a0d5e2d7a0ed786a205d3f778cacfcc7b659e17807</span><br>
md5 <span class="stamp-md5">a8928d81125e2276e6724cc39d28d782</span><br>
sha256 <span class="stamp-sha">1f7f5b419172ae5a15edcbb63b160d9bb63d88ec7809e30e22b9ea75533e9c72</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>