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');