zebra-report: deploy self-listener mode

Speakers, cohosts, and hosts can flip their own row's stream toggle
to listen via the buffered HTTP Ogg/Opus broadcast tap instead of
WebRTC. Mic auto-mutes while in that mode; unmuting hops back to the
live mesh seamlessly. Survives cellular ICE churn that breaks the
WebRTC path.
This commit is contained in:
russell@unturf.com 2026-06-04 10:29:34 -04:00
parent f39f6a4ea9
commit d9e8041a37
No known key found for this signature in database

View file

@ -3481,6 +3481,20 @@ async function handleSignal(raw){
* present-member-offers: existing speaker offers when a new speaker
* arrives. deterministic by uuid string compare. */
if (canSpeak(myRole) && canSpeak(m.role)) connectToPeer(m.uuid, /*weOffer*/ myUUID < m.uuid);
/* if we're in self-listener mode and a new speaker just arrived,
* pull them onto the buffered HTTP stream too so we hear them on
* the same path as everyone else */
if (selfListenerMode && canSpeak(m.role) && m.uuid !== myUUID && m.pubkey){
try {
const ph = hex(unb64(m.pubkey));
if (ph && !streamMode.has(ph)){
streamMode.add(ph);
startStream(m.uuid, ph);
const w = remoteAudio.get(m.uuid);
if (w) try { w.muted = true; } catch(_){}
}
} catch(_){}
}
renderRoom();
break;
case 'peer-left':
@ -3819,6 +3833,10 @@ async function onRoleChanged(prev, next){
* people out of the mesh automatically'. The mesh connections
* stay up as a bonus low-latency audio path; they get GC'd
* naturally when the other end leaves or also demotes. */
/* self-listener flag is meaningless once we're a listener (the
* row's stream toggle disappears); flip it OFF so its streamMode
* entries get torn down cleanly with the rest of our state. */
if (selfListenerMode) disableSelfListenerMode();
dropMic(); muted = false;
await sfuUnpublish();
await sfuUnpublishScreen();
@ -4076,6 +4094,12 @@ const streamAudio = new Map(); // uuid -> <audio> pulling stream
* Muted starts TRUE so the visible button starts as 'play' — fox: the
* canonical 'I want sound' moment is when they tap that button. */
let listenerOutputMuted = true;
/* Self-listener mode: a speaker / cohost / host has flipped their own
* row's stream toggle to consume the room via the buffered HTTP Ogg/
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
* they can't talk into a delayed stream (they'd be 2-4s behind the
* conversation); unmuting toggles them back to WebRTC seamlessly. */
let selfListenerMode = false;
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh <audio>
* with src URL and a hard refresh starts the same loop. WebRTC stays
@ -4125,6 +4149,7 @@ async function startStream(uuid, pubHex){
* in-flight load — Firefox surfaces this as 'fetching aborted at
* user request' which we'd mistake for autoplay block. */
if (a && a.src === wantUrl && !a.error) {
logLine('', 'stream: skip dup for '+pubHex.slice(0,12)+' (already loading/playing)');
return;
}
if (!a){
@ -4142,6 +4167,15 @@ async function startStream(uuid, pubHex){
* DJ-mode on remote speakers) was playing silently. Unmute on every
* call so a reused element from a prior stop also re-monitors. */
try { a.muted = false; } catch(_){}
/* Diagnostic breadcrumbs — earlier sessions clicked the toggle and
* the SFU log saw zero /stream GETs. Track loadstart (fetch began),
* canplay (data arriving), stalled (TCP stuck) so we can tell on
* which leg the path breaks next time. once:false so we see every
* recovery cycle. */
a.addEventListener('loadstart', () => logLine('', 'stream loadstart '+pubHex.slice(0,12)), { once: true });
a.addEventListener('canplay', () => logLine('', 'stream canplay '+pubHex.slice(0,12)), { once: true });
a.addEventListener('stalled', () => logLine('err','stream stalled '+pubHex.slice(0,12)));
logLine('', 'stream open: '+pubHex.slice(0,12)+' → '+wantUrl);
a.src = wantUrl;
const onPlaying = () => {
logLine('', 'stream on for '+pubHex.slice(0,12)+' — DJ mode (~2s delay, glitch-free)');
@ -4151,7 +4185,12 @@ async function startStream(uuid, pubHex){
logLine('err', 'stream for '+pubHex.slice(0,12)+' '+why+' — staying on live WebRTC');
};
a.addEventListener('playing', onPlaying, { once: true });
a.addEventListener('error', () => onFail('error'), { once: true });
a.addEventListener('error', (ev) => {
const err = a.error;
const code = err ? err.code : '?';
const msg = err ? (err.message || '') : '';
onFail('error code='+code+' msg='+msg);
}, { once: true });
try {
const p = a.play();
if (p && p.catch) p.catch(e => onFail('autoplay blocked: '+e.message));
@ -4181,6 +4220,68 @@ function toggleStreamFor(uuid, pubHex){
renderRoom();
}
/* selfListenerMode — a speaker/cohost/host who wants to consume the
* room via the buffered HTTP Ogg/Opus path instead of the live WebRTC
* mesh. Useful when WebRTC ICE / DTLS dies under cellular churn or
* the user just wants the higher-fidelity broadcast pipeline.
*
* Coupled to mute: turning it ON auto-mutes (you'd be 2-4s behind the
* conversation; talking into that delay is hopeless). Unmuting flips
* it OFF so the user is seamlessly back on the live mesh.
*
* Mechanics: populate streamMode with every audible peer's pubHex,
* fire startStream() for each so we open the HTTP Ogg/Opus pull, and
* mute the corresponding remoteAudio (WebRTC) elements so we don't
* hear both paths at once. disable* tears everything down + restores
* the WebRTC playback. */
async function enableSelfListenerMode(){
if (selfListenerMode) return;
selfListenerMode = true;
/* auto-mute mic before we start playing the delayed stream */
if (micStream && !muted){
muted = true;
try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){}
applyMuteState();
try { sendMicState(); } catch(_){}
}
let added = 0;
for (const [uuid, mm] of members){
if (uuid === myUUID || !canSpeak(mm.role) || !mm.pubkey) continue;
let pubHex;
try { pubHex = hex(unb64(mm.pubkey)); } catch(_){ continue; }
if (streamMode.has(pubHex)) continue;
streamMode.add(pubHex);
startStream(uuid, pubHex);
const w = remoteAudio.get(uuid);
if (w) try { w.muted = true; } catch(_){}
added++;
}
renderRoom();
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path, mic muted');
}
function disableSelfListenerMode(){
if (!selfListenerMode) return;
selfListenerMode = false;
for (const pubHex of [...streamMode]){
let foundUuid = null;
for (const [u, mm] of members){
try { if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ foundUuid = u; break; } } catch(_){}
}
streamMode.delete(pubHex);
if (foundUuid) stopStream(foundUuid);
}
/* restore WebRTC playback for every remote — stopStream already
* unmutes the matched uuid; this catches any others we couldn't
* resolve (e.g. member dropped while we were in DJ mode). */
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
renderRoom();
logLine('', 'self-listener OFF — back to live WebRTC mesh');
}
function toggleSelfListenerMode(){
if (selfListenerMode) disableSelfListenerMode();
else enableSelfListenerMode();
}
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
* <audio> element keeps a deep media buffer (~30s in Chrome) that
@ -4293,29 +4394,20 @@ function renderRoom(){
* non-mods (just-self-monitor self-rows), which fox flagged as
* causing vertical scroll. Single-char glyph keeps the column 1.4rem. */
let streamEl = null;
if (canSpeak(m.role) && m.pubkey){
let mPubHex = '';
try { mPubHex = hex(unb64(m.pubkey)); } catch(_){}
if (mPubHex){
const isSelf = (m.uuid === myUUID);
const canStreamThisRow = isSelf || myRole === 'host';
if (canStreamThisRow){
const on = streamMode.has(mPubHex);
streamEl = document.createElement('button');
streamEl.className = 'stream-toggle' + (on ? ' on' : '');
streamEl.textContent = on ? '◉' : '○';
if (isSelf){
streamEl.title = on
? 'monitoring your DJ stream — click to stop (feedback risk on open speakers!)'
: 'preview what listeners hear of YOUR mic (~2s delay) — headphones recommended';
} else {
streamEl.title = on
? 'streaming — click to switch back to live WebRTC'
: 'switch this speaker to HTTP Ogg/Opus stream (~2s delay, glitch-free)';
}
streamEl.onclick = () => toggleStreamFor(m.uuid, mPubHex);
}
}
/* Per-row stream toggle. The ONLY row that gets one is SELF — and
* only when we can speak (host/cohost/speaker). The button flips
* THIS viewer's whole audio path from the live WebRTC mesh to the
* buffered HTTP Ogg/Opus broadcast tap (the listener-quality
* stream). selfListenerMode is global, so the ◉/○ glyph reflects
* that flag rather than streamMode of any single pubHex. */
if (canSpeak(m.role) && m.uuid === myUUID && canSpeak(myRole)){
streamEl = document.createElement('button');
streamEl.className = 'stream-toggle' + (selfListenerMode ? ' on' : '');
streamEl.textContent = selfListenerMode ? '◉' : '○';
streamEl.title = selfListenerMode
? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
: 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic';
streamEl.onclick = () => toggleSelfListenerMode();
}
if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){
@ -4651,10 +4743,15 @@ function applyMuteState(){
}
$('btn-mute').addEventListener('click', () => {
if (!micStream) return;
const wasMuted = muted;
muted = !muted;
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){}
applyMuteState();
sendMicState();
/* Unmuting while self-listener-mode is on means "I want to talk
* again" — tear down the buffered HTTP streams and restore the live
* WebRTC mesh so the user is back in the now of the conversation. */
if (wasMuted && !muted && selfListenerMode) disableSelfListenerMode();
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
@ -4895,6 +4992,7 @@ $('btn-leave').addEventListener('click', async () => {
}
streamAudio.clear();
streamMode.clear();
selfListenerMode = false;
for (const u of [...peers.keys()]) tearPeer(u);
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
* leave (user clicked leave / closed tab) from a hiccup disconnect
@ -4944,8 +5042,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">669ef155bc5fd5e8a1452e593f3b257d</span><br>
sha256 <span class="stamp-sha">1242cf6e1835f9e2e1bb40c51b3b83cc7ed2c9bcd69b6593a5bea70835054ac5</span><br>
md5 <span class="stamp-md5">398d9aaa80adc3b9c5b1b7320f095834</span><br>
sha256 <span class="stamp-sha">151bbeb161be4bffaf10bf71249fe5ba6fd1d4bc22b2c948dbbdb540574b6bf7</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>