diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index f4fb041..24ca444 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -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');