From 0983639b2437e41975fb78aec86e0a8ef76d1a48 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 4 Jun 2026 13:10:30 -0400 Subject: [PATCH] zebra-report: deploy self-listener FSM (state-machine driven) --- zebra-report/zebra-spaces.html | 118 +++++++++++++++++++++++---------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 8e7b5dd..ae7faaf 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -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