zebra-spaces: tear down stale <audio> fallback when worklet path takes over

Fox 2026-06-05: "still two feeds one early (one on time) the early
one must be the mesh for the speakers arriving to the listener" /
"fxhp-android-firefox is also hearing two, one perfectly synced,
other is not."

It was NOT mesh (listeners have no mesh PCs — telemetry confirmed
mesh=0). It was the <audio>-element fallback racing the worklet:

Sequence per peer-joined cycle:
  1. flushSfuStreams handed a stale cached stream (no live audio
     tracks after the publisher's prior session evicted) to
     attachSfuTrack.
  2. attachListenerStreamViaAudioContext rejected it via the new
     "no live audio tracks" guard.
  3. attachSfuTrack FELL THROUGH to the <audio>-element fallback,
     created an <audio>, set srcObject = stale stream, called
     play(), and stored it in remoteAudio[uuid].
  4. Later, live ontrack delivered the real audio stream.
     attachListenerStreamViaAudioContext succeeded, the worklet
     started, and attachSfuTrack returned early.
  5. The <audio> element from step 3 was NEVER torn down. The SFU's
     still-alive stale transceiver kept forwarding RTP into it.
  6. Listener heard the same publisher twice — <audio> at ~0.5s
     native jbuf ("early"), worklet at 4s ("on time").

Fix: when the worklet/AudioContext path succeeds, look up the
stale <audio> element in remoteAudio[uuid] and tear it down
(srcObject=null, pause, remove, delete from map). Single playback
path per uuid going forward. Applies to both listener and
speaker/cohost/host branches.
This commit is contained in:
Russell Ballestrini 2026-06-05 19:52:43 -04:00
parent 144dd15a53
commit 61b21a68e2
No known key found for this signature in database

View file

@ -3392,6 +3392,20 @@ function attachSfuTrack(uuid, stream){
* AudioContext where <audio>.play() doesn't. */ * AudioContext where <audio>.play() doesn't. */
if (myRole === 'listener'){ if (myRole === 'listener'){
if (attachListenerStreamViaAudioContext(uuid, stream)){ if (attachListenerStreamViaAudioContext(uuid, stream)){
/* If a stale-stream attempt earlier in this lifecycle left an
* <audio> fallback element in remoteAudio[uuid], the SFU's still-
* alive transceiver continues feeding RTP into it. Now that the
* worklet path is the live audio source, tear the <audio> element
* down so we don't play the same publisher twice — once via
* <audio> (~0.5s native jbuf = "early") and once via worklet (4s
* = "on time"). Fox 2026-06-05: "fxhp-android-firefox is also
* hearing two, one perfectly synced, other is not." */
const stale = remoteAudio.get(uuid);
if (stale){
try { stale.srcObject = null; stale.pause(); stale.remove(); } catch(_){}
remoteAudio.delete(uuid);
logLine('', 'detached stale <audio> fallback for '+uuid.slice(0,4));
}
stopMeter(uuid); startMeter(uuid, stream); stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx 4s)'); logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx 4s)');
return; return;
@ -3401,6 +3415,12 @@ function attachSfuTrack(uuid, stream){
} else { } else {
/* speaker / cohost / host: smaller buffer, same path. */ /* speaker / cohost / host: smaller buffer, same path. */
if (attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC)){ if (attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC)){
const stale = remoteAudio.get(uuid);
if (stale){
try { stale.srcObject = null; stale.pause(); stale.remove(); } catch(_){}
remoteAudio.delete(uuid);
logLine('', 'detached stale <audio> fallback for '+uuid.slice(0,4));
}
stopMeter(uuid); startMeter(uuid, stream); stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx '+SPEAKER_PLAYOUT_DELAY_SEC+'s)'); logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx '+SPEAKER_PLAYOUT_DELAY_SEC+'s)');
return; return;
@ -7839,8 +7859,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"> <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-05</span><br> <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-05</span><br>
md5 <span class="stamp-md5">bebca66d15d021b0f695814f8b0330f3</span><br> md5 <span class="stamp-md5">14097cba6e18c225634093484e581807</span><br>
sha256 <span class="stamp-sha">1489ff6f3224001b234fdffec8c616127b3ccf4812e410e188e892ce2dec1875</span><br> sha256 <span class="stamp-sha">a48c12affaea0497df78d186a44649081c342148ad993bd529ab216c55de8a88</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">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> <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> </footer>