zebra-report: deploy AudioContext listener audio path (Firefox Android autoplay fix)

This commit is contained in:
russell@unturf.com 2026-06-04 11:46:27 -04:00
parent 10d705beb3
commit 9d6b27842d
No known key found for this signature in database

View file

@ -1638,7 +1638,67 @@ const gameVideos = new Map(); // pubHex -> { tile, video }
* even when no fresh ontrack event arrives. */
const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
/* Web Audio fallback for listeners. Firefox Android grants <audio>.play()
* autoplay engagement PER ELEMENT PER SOURCE — playing a SILENCE_WAV in
* the pre-blessed pool at entry-click does NOT carry over when
* srcObject is later swapped to a WebRTC MediaStream. Telemetry from
* fox 2026-06-04 confirmed: receiver decodes audio (level=0.399 in
* inbound-rtp stats) but every <audio>.play() rejects with
* "play method is not allowed by the user agent" — pool exhausts,
* recovery still fails. AudioContext has a different gesture model:
* one resume() inside the user gesture covers every MediaStreamSource
* routed through it, no per-source re-engagement needed.
*
* Listener-only because speakers/cohosts/hosts have an active mic +
* setSinkId requirements that still want <audio> elements. Listeners
* don't pick speaker output devices (no UI for it) and don't talk —
* the AudioContext path is simpler and survives Firefox Android. */
const listenerAudioNodes = new Map(); /* uuid -> { src, gain, stream } */
function attachListenerStreamViaAudioContext(uuid, stream){
if (!audioCtx){
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ logLine('err','listener audioCtx create: '+e.message); return false; }
}
if (audioCtx.state === 'suspended'){
audioCtx.resume().catch(()=>{});
}
const existing = listenerAudioNodes.get(uuid);
if (existing){
try { existing.src.disconnect(); existing.gain.disconnect(); } catch(_){}
listenerAudioNodes.delete(uuid);
}
let src;
try { src = audioCtx.createMediaStreamSource(stream); }
catch(e){ logLine('err','listener createMediaStreamSource '+uuid.slice(0,4)+': '+e.message); return false; }
const gain = audioCtx.createGain();
gain.gain.value = 1.0;
src.connect(gain);
gain.connect(audioCtx.destination);
listenerAudioNodes.set(uuid, { src, gain, stream });
logLine('', 'listener audio via AudioContext '+uuid.slice(0,4)+' ctxState='+audioCtx.state);
return true;
}
function detachListenerStream(uuid){
const node = listenerAudioNodes.get(uuid);
if (!node) return;
try { node.src.disconnect(); node.gain.disconnect(); } catch(_){}
listenerAudioNodes.delete(uuid);
}
function attachSfuTrack(uuid, stream){
/* Listener role: route audio through AudioContext (Firefox Android
* autoplay survives this path; <audio>.play() doesn't). Meter still
* uses the existing startMeter() path which separately creates its
* own analyser source from the same stream — that's fine, multiple
* MediaStreamSource nodes per stream is allowed. */
if (myRole === 'listener'){
if (attachListenerStreamViaAudioContext(uuid, stream)){
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx)');
return;
}
/* AudioContext failed — fall through to <audio> path as last resort */
logLine('err','listener audioctx attach failed, falling back to <audio>');
}
let a = remoteAudio.get(uuid);
const fresh = !a;
if (!a){
@ -3661,6 +3721,7 @@ async function handleSignal(raw){
spotlights.delete(m.uuid);
tearPeer(m.uuid);
clearMeshRetryState(m.uuid);
detachListenerStream(m.uuid);
if (hostUUID === m.uuid) hostUUID = '';
renderRoom();
reorderTiles();
@ -5114,7 +5175,17 @@ function primeAudioOnGesture(){
if (p && p.then) p.then(() => {
setTimeout(() => { try { a.remove(); } catch(_){} }, 500);
}).catch(()=>{ try { a.remove(); } catch(_){} });
try { if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); } catch(_){}
/* Create audioCtx if absent + resume so the listener path
* (attachListenerStreamViaAudioContext) has a granted-and-running
* AudioContext by the time the first remote MediaStream arrives.
* Listeners never grab a mic so without this, audioCtx would be
* null forever and the Web Audio listener route couldn't bootstrap.
* Speakers also benefit — meter creation later won't have to
* resume-from-suspended (which can fail outside a gesture). */
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume().catch(()=>{});
} catch(e){ logLine('err', 'audioCtx prime: '+e.message); }
/* pre-bless a pool of audio elements for unmuted autoplay. Each one
* gets play() called inside this click handler — the gesture grants
* each element individual engagement that survives the click's
@ -5271,8 +5342,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">10ede11a8d0ae2961b6c9736a66ece97</span><br>
sha256 <span class="stamp-sha">ab67e30e56518083e4ecb975f20c63f41a32c8c9b2003f3e3fa7dc0f09a31b79</span><br>
md5 <span class="stamp-md5">49f9782cba53987585b1e8e4bb9cd6bd</span><br>
sha256 <span class="stamp-sha">b89d91a064e4b657ebe7f7b4bbcc85d935f7be141679369f88913c8d07a41e80</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>