zebra-report: deploy AudioContext listener audio path (Firefox Android autoplay fix)
This commit is contained in:
parent
10d705beb3
commit
9d6b27842d
1 changed files with 74 additions and 3 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue