zebra-report: deploy silent wedge-recovery for stuck audio elements

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

View file

@ -1667,14 +1667,16 @@ function attachSfuTrack(uuid, stream){
(t0 ? ' tr0={en='+t0.enabled+' mu='+t0.muted+' rs='+t0.readyState+'}' : ''));
try {
const p = a.play();
if (p && p.catch) p.catch(e => {
logLine('err','rtc autoplay '+uuid.slice(0,4)+': '+e.message);
flagAudioBlocked('rtc autoplay '+uuid.slice(0,4));
});
if (p && p.catch) p.catch(e => logLine('err','rtc autoplay '+uuid.slice(0,4)+': '+e.message));
} catch(e){
logLine('err','rtc play threw '+uuid.slice(0,4)+': '+e.message);
flagAudioBlocked('rtc play threw '+uuid.slice(0,4));
}
/* If play() returns a resolved promise but the element stays paused
* at ct=0 (Firefox Android srcObject-swap kills the pre-blessed
* engagement silently), the telemetry tick's wedge-recovery will
* detect that within 5s and lease a fresh pool element. No user-
* visible notice — fox: tap-anywhere from 6c9d1b8 was rejected for
* adding noise, automatic silent recovery is the right shape. */
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid));
}
@ -3424,12 +3426,17 @@ async function dumpTelemetry(){
} catch(e){ parts.push('stats.err=' + e.message); }
}
/* every <audio> element in the room — both the WebRTC remoteAudio
* pool (rtc[]) and the DJ HTTP-pull streamAudio (stream[]). */
* pool (rtc[]) and the DJ HTTP-pull streamAudio (stream[]). Capture
* wedged elements (rs>=2 HAVE_CURRENT_DATA but paused at ct=0) so
* the recovery path below can re-lease fresh pool elements for
* them. This mimics what host hard-refresh achieves: fresh
* attachSfuTrack on a fresh element from the pre-blessed pool. */
const wedgedRtc = [];
let i = 0;
let pausedZeroCt = 0, totalAudio = 0;
for (const [uuid, a] of remoteAudio){
totalAudio++;
if (a.paused && a.currentTime === 0 && a.readyState >= 2) pausedZeroCt++;
if (a.paused && a.currentTime === 0 && a.readyState >= 2 && a.srcObject){
wedgedRtc.push([uuid, a]);
}
parts.push('rtc[' + uuid.slice(0,4) + '] rs=' + a.readyState + ' ns=' + a.networkState +
' pa=' + (a.paused?1:0) + ' mu=' + (a.muted?1:0) + ' ct=' + a.currentTime.toFixed(2) +
' err=' + (a.error?a.error.code:'_'));
@ -3437,8 +3444,6 @@ async function dumpTelemetry(){
}
i = 0;
for (const [uuid, a] of streamAudio){
totalAudio++;
if (a.paused && a.currentTime === 0 && a.readyState >= 2) pausedZeroCt++;
parts.push('stream[' + uuid.slice(0,4) + '] rs=' + a.readyState + ' ns=' + a.networkState +
' pa=' + (a.paused?1:0) + ' mu=' + (a.muted?1:0) + ' ct=' + a.currentTime.toFixed(2) +
' src=' + (a.src ? (a.src.length>30 ? '…'+a.src.slice(-30) : a.src) : 'none') +
@ -3446,18 +3451,34 @@ async function dumpTelemetry(){
if (++i > 4) { parts.push('stream…+' + (streamAudio.size - i) + ' more'); break; }
}
logLine('', '· ' + parts.join(' '));
/* State-based autoplay-block detection: if any audio element has data
* (rs>=2 HAVE_CURRENT_DATA) but stays paused at ct=0, play() either
* silently failed or the pre-blessing wore off when srcObject was
* swapped. The Firefox Android phone hits this consistently: receiver
* decodes audio (level>0 in aud.recv stats) but the <audio> stays
* pa=1 ct=0.00 forever. play()-promise-reject doesn't fire in that
* shape, so a state-based trigger is the only reliable detector.
* Only fire on listener role — speakers/cohosts/hosts get the same
* pre-blessing path but also have an active mic that requires its
* own gesture chain we don't want to disturb. */
if (pausedZeroCt > 0 && totalAudio > 0 && myRole === 'listener'){
flagAudioBlocked('telemetry: '+pausedZeroCt+'/'+totalAudio+' audio paused at ct=0');
/* Auto-recovery for wedged WebRTC audio elements. The Firefox
* Android phone consistently gets stuck with pa=1 ct=0 even though
* the receiver's aud.recv stat shows level>0 (real audio decoding).
* Reproduced by fox 2026-06-04: "hard refresh on host fixed the
* phone" — because the host's republish triggers a fresh ontrack
* on the phone, which leases a NEW pool element with intact
* autoplay blessing. We mirror that mechanism here: when an element
* is wedged, lease a fresh pool element, rebind the same stream,
* play(), swap into remoteAudio. The old wedged element gets
* removed from the DOM. tap-anywhere was tried in 6c9d1b8 and
* reverted in 24bfc81 — fox: don't add user-visible noise. This
* is a silent automatic recovery instead. */
for (const [uuid, oldEl] of wedgedRtc){
const stream = oldEl.srcObject;
if (!stream) continue;
try { oldEl.pause(); } catch(_){}
try { oldEl.srcObject = null; } catch(_){}
try { oldEl.remove(); } catch(_){}
const fresh = leaseAudioElement();
remoteAudio.set(uuid, fresh);
applySinkTo(fresh);
fresh.addEventListener('playing', () => logLine('', 'rtc recover '+uuid.slice(0,4)+' playing'), { once: true });
fresh.srcObject = stream;
try {
const p = fresh.play();
if (p && p.catch) p.catch(e => logLine('err','rtc recover '+uuid.slice(0,4)+' play rejected: '+e.message));
} catch(e){ logLine('err','rtc recover '+uuid.slice(0,4)+' play threw: '+e.message); }
logLine('', 'rtc wedge-recovery '+uuid.slice(0,4)+' — fresh pool element bound');
}
}
function startTelemetryLoop(){
@ -4315,33 +4336,12 @@ function activateListenerAudio(){
for (const [uuid, a] of streamAudio){ tryPlay(a, 'stream', uuid); }
logLine('', 'activateListenerAudio played='+played+' failed='+failed+' rtc='+remoteAudio.size+' stream='+streamAudio.size);
}
/* Auto-recovery hook for Firefox Android autoplay block: when
* attachSfuTrack's play() rejects, set audioPlaybackBlocked=true and
* surface a single notice. The next click anywhere on the page calls
* activateListenerAudio() inside the gesture, which re-tries play()
* on every paused element. One tap, all audio unblocks.
*
* Scoped — the document-click listener is armed only while the flag
* is true. Cleared the moment any audio resumes. Doesn't add noise
* to working sessions. */
let audioPlaybackBlocked = false;
function flagAudioBlocked(why){
if (audioPlaybackBlocked) return;
audioPlaybackBlocked = true;
logLine('err', 'audio blocked: '+why+' — tap anywhere to resume');
showNotice('Audio paused by browser. Tap anywhere to resume.', 'warn');
const handler = () => {
document.removeEventListener('click', handler, true);
document.removeEventListener('touchstart', handler, true);
activateListenerAudio();
/* the play() promises resolve async, but we can clear the flag
* optimistically — if any of them re-reject we'll re-flag. */
audioPlaybackBlocked = false;
hideNotice();
};
document.addEventListener('click', handler, true);
document.addEventListener('touchstart', handler, true);
}
/* activateListenerAudio above remains as a manual recovery primitive
* (callable from a future UI button if needed), but the page no
* longer arms a global tap-anywhere listener — fox 2026-06-04: tap-
* anywhere from 6c9d1b8 was rejected for adding user-visible noise.
* Silent automatic recovery via the telemetry tick's wedge-detect +
* pool re-lease is the shape that ships. */
function applyAudioMute(){
/* legacy hook still called by attachSfuTrack / startStream; for the
* listener-tap-to-play flow we now do the work in activateListener-
@ -5271,8 +5271,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">c84aa4f6e7bc464e3712a8e07b7f1052</span><br>
sha256 <span class="stamp-sha">520f5e0765afe3a6345f84236d56c53c5ec751f83992ffe9e902268e5d0702cd</span><br>
md5 <span class="stamp-md5">10ede11a8d0ae2961b6c9736a66ece97</span><br>
sha256 <span class="stamp-sha">ab67e30e56518083e4ecb975f20c63f41a32c8c9b2003f3e3fa7dc0f09a31b79</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>