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:
parent
4d48df96e4
commit
ba5583776d
1 changed files with 58 additions and 7 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue