zebra-report: deploy self-listener FSM (state-machine driven)

This commit is contained in:
russell@unturf.com 2026-06-04 13:10:30 -04:00
parent c578779398
commit 0983639b24
No known key found for this signature in database

View file

@ -1248,18 +1248,61 @@ const callSpec = {
};
/* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of
* RemoteTileFSMs into a coherent room. Observers wire transitions
* between machines; no side effects in this layer — the page's
* runtime attaches its OWN observers on top to drive actual WebRTC
* and DOM work. That separation keeps this function fully testable
* in Node with synthetic events.
* SelfListenerFSM — a speaker / cohost / host who's flipped their
* own row's stream toggle to consume the room via the buffered HTTP
* Ogg/Opus path instead of the live WebRTC mesh.
*
* Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */
* off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off
*
* The UNMUTE edge encodes fox's invariant: "unmuting should
* seamlessly switch them back to the now of the conversation
* webrtc mesh" — if the user clicks unmute while in on, they
* implicitly want to go back to the live path.
*
* Pure spec — side effects (mic mute, streamMode population,
* remoteAudio muting) live in the runtime's observer attached to
* this FSM. Keeps it testable in Node. */
const selfListenerSpec = {
initial: 'off',
context: { enrolledCount: 0 },
states: {
off: {
on: {
TOGGLE: 'on',
ENABLE: 'on',
UNMUTE: 'off', /* no-op self-transition for symmetry */
DEMOTED: 'off',
CLEAR: 'off',
},
},
on: {
on: {
TOGGLE: 'off',
DISABLE: 'off',
UNMUTE: 'off',
DEMOTED: 'off',
CLEAR: 'off',
},
},
},
};
/* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), one
* SelfListenerFSM, and a Map of RemoteTileFSMs into a coherent
* room. Observers wire transitions between machines; no side
* effects in this layer — the page's runtime attaches its OWN
* observers on top to drive actual WebRTC and DOM work. That
* separation keeps this function fully testable in Node with
* synthetic events.
*
* Returns { call, sub, pubs, selfListener, remoteTiles, tileFor,
* tileLeft }. */
function wireZebraMachines(){
const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec);
const selfListener = createFSM(selfListenerSpec);
const pubs = {
mic: createFSM(publishSpec),
screen: createFSM(publishSpec),
@ -1315,7 +1358,7 @@ function wireZebraMachines(){
}
});
return { call, sub, pubs, remoteTiles, tileFor, tileLeft };
return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft };
}
/* ==================================================================
@ -4172,7 +4215,7 @@ async function onRoleChanged(prev, next){
/* 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();
roomMachines.selfListener.send('DEMOTED');
dropMic(); muted = false;
await sfuUnpublish();
await sfuUnpublishScreen();
@ -4492,7 +4535,14 @@ let listenerOutputMuted = true;
* 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;
/* selfListenerMode is now a derived getter against the SelfListenerFSM
* (roomMachines.selfListener). Single source of truth — direct writes
* to the boolean would drift from the FSM state, defeating the point.
* All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' |
* 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer
* attached during room setup (see selfListenerObserver below). */
function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; }
Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get });
/* 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
@ -4636,9 +4686,10 @@ function toggleStreamFor(uuid, pubHex){
* 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;
/* Side-effect runners — invoked ONLY by the SelfListenerFSM observer
* below. No internal state mutation, no idempotency guards (the FSM
* handles re-entry by never emitting a same-state transition). */
function runSelfListenerEnable(){
/* auto-mute mic before we start playing the delayed stream */
if (micStream && !muted){
muted = true;
@ -4659,20 +4710,16 @@ async function enableSelfListenerMode(){
if (streamMode.has(pubHex)) continue;
streamMode.add(pubHex);
startStream(uuid, pubHex);
/* mute the matched WebRTC remote so we don't hear both paths.
* Self has no remoteAudio entry (we never subscribe to ourselves
* via SFU — see the self-echo skip in handleRemoteSfuTrack), so
* this is a no-op for the self row. */
/* mute the matched WebRTC remote so we don't hear both paths. */
const w = remoteAudio.get(uuid);
if (w) try { w.muted = true; } catch(_){}
added++;
}
roomMachines.selfListener.context.enrolledCount = added;
renderRoom();
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
}
function disableSelfListenerMode(){
if (!selfListenerMode) return;
selfListenerMode = false;
function runSelfListenerDisable(){
for (const pubHex of [...streamMode]){
let foundUuid = null;
for (const [u, mm] of members){
@ -4681,17 +4728,18 @@ function disableSelfListenerMode(){
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). */
/* restore WebRTC playback for every remote */
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
roomMachines.selfListener.context.enrolledCount = 0;
renderRoom();
logLine('', 'self-listener OFF — back to live WebRTC mesh');
}
function toggleSelfListenerMode(){
if (selfListenerMode) disableSelfListenerMode();
else enableSelfListenerMode();
}
/* Observer: side effects fire on every off↔on transition. */
roomMachines.selfListener.observe(({ state, prev }) => {
if (prev === null || state === prev) return;
if (state === 'on') runSelfListenerEnable();
else runSelfListenerDisable();
});
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
@ -4818,7 +4866,7 @@ function renderRoom(){
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();
streamEl.onclick = () => roomMachines.selfListener.send('TOGGLE');
}
if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){
@ -5161,8 +5209,10 @@ $('btn-mute').addEventListener('click', () => {
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();
* WebRTC mesh so the user is back in the now of the conversation.
* The UNMUTE edge on the SelfListenerFSM encodes this — it's a
* no-op when already off, drops to off when on. */
if (wasMuted && !muted) roomMachines.selfListener.send('UNMUTE');
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
@ -5452,7 +5502,7 @@ $('btn-leave').addEventListener('click', async () => {
}
streamAudio.clear();
streamMode.clear();
selfListenerMode = false;
roomMachines.selfListener.send('CLEAR');
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
@ -5502,8 +5552,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">0c3035d2de972b24edd97e8b6130f1f3</span><br>
sha256 <span class="stamp-sha">7aa8d55df2a764566d1b1af276961c46109189f992b357a00b6201be94559339</span><br>
md5 <span class="stamp-md5">12a9280b3fcf75a793ad9b6a1b0ebcd1</span><br>
sha256 <span class="stamp-sha">1562148740ec1d0ca0211574d03f5670757f86c663abd98aee47f951eddc3c48</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>