From 7f9d8273c9512551c9ff5a8c0098b63dd20a08c4 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 14:13:24 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20MuteFSM=20=E2=80=94=20lift=20mu?= =?UTF-8?q?ted=20state=20to=20a=20finite=20state=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `muted` was a bare global mutated from 8+ sites (btn-mute click, peer-force-muted, role promote/demote, self-listener enable/disable, leave handler, sessionStorage restore). Each call site also had to remember to call applyMuteState() and sendMicState(). Drift was inevitable — a recent regression where self-listener toggle muted the wrong direction came straight from this implicit-state pile. New shape: - muteSpec: states { on, off }, events { TOGGLE, FORCE_MUTE, AUTO_MUTE, AUTO_UNMUTE, RESTORE_MUTED, RESTORE_UNMUTED, ROLE_PROMOTED }, ctx.source tracks who muted us ('self', 'mod', 'self-listener'). - `muted` is now a getter over roomMachines.mute.state — single source of truth. - One observer drives applyMuteState + sendMicState + sessionStorage persistence + log line on every transition. - Imperative call sites only dispatch events; they no longer touch side effects. Tests: test/zebra-fsm.test.js harness updated to extract muteSpec (same brace- matched-regex pattern as selfListenerSpec). 88/0 passing. MuteFSM-specific transition tests are next. Pattern is now load-bearing — call/publish/subscribe/remote-tile/self-listener/ mute all live as FSMs with the same shape. --- test/zebra-fsm.test.js | 7 +- web/zebra-spaces.html | 178 +++++++++++++++++++++++++++++------------ 2 files changed, 132 insertions(+), 53 deletions(-) diff --git a/test/zebra-fsm.test.js b/test/zebra-fsm.test.js index c0fecc3..0d55911 100644 --- a/test/zebra-fsm.test.js +++ b/test/zebra-fsm.test.js @@ -35,16 +35,17 @@ const subscribeSpecSrc = extract(/const subscribeSpec = /); const remoteTileSpecSrc = extract(/const remoteTileSpec = /); const callSpecSrc = extract(/const callSpec = /); const selfListenerSpecSrc = extract(/const selfListenerSpec = /); +const muteSpecSrc = extract(/const muteSpec = /); const wireMachinesSrc = extract(/function wireZebraMachines\(/); /* Function-constructor scope so `const` declarations are visible at the * harness's `return` — they would NOT leak through a bare `eval()`. */ const harness = new Function( createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + - remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + wireMachinesSrc + - '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines };' + remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + muteSpecSrc + '\n' + wireMachinesSrc + + '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, muteSpec, wireZebraMachines };' ); -const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines } = harness(); +const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, muteSpec, wireZebraMachines } = harness(); let pass = 0, fail = 0; function test(name, fn){ diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index bd174e4..3c36d0b 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -1290,11 +1290,56 @@ const selfListenerSpec = { }, }; +/* ================================================================== + * MuteFSM — speaker's own mic mute state. Two states (on / off) + * with the source tracked in ctx so observers can render the right + * UI ("muted by mod", "muted by self-listener", etc.) and the + * unmute logic knows whether to clear the source. + * + * on ──TOGGLE / FORCE_MUTE / AUTO_MUTE / RESTORE_MUTED──▶ off + * off ──TOGGLE / AUTO_UNMUTE / RESTORE_UNMUTED──▶ on + * + * `source` (null when on, set when off): + * - 'self' — user clicked btn-mute / restored from session + * - 'mod' — server peer-force-muted + * - 'self-listener' — auto-muted because self-listener-mode enabled + * + * Pure spec — side effects (track.enabled, btn-mute text/class, + * member-row icon, sendMicState broadcast) ride observers attached + * during room setup. */ +const muteSpec = { + initial: 'on', + context: { source: null }, + states: { + on: { + entry: (ctx) => { ctx.source = null; }, + on: { + TOGGLE: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, + FORCE_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'mod'; } }, + AUTO_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'self-listener'; } }, + RESTORE_MUTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, + ROLE_PROMOTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, /* default-mute on promote so no hot-mic surprises */ + RESTORE_UNMUTED: 'on', + }, + }, + off: { + on: { + TOGGLE: 'on', + AUTO_UNMUTE: 'on', /* self-listener-mode exited */ + RESTORE_UNMUTED: 'on', + RESTORE_MUTED: { target: 'off', action: (ctx) => { ctx.source = 'self'; } }, /* self-transition keeps source */ + FORCE_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'mod'; } }, /* upgrade source to mod */ + AUTO_MUTE: { target: 'off', action: (ctx) => { ctx.source = 'self-listener'; } }, + }, + }, + }, +}; + /* ================================================================== * 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 + * SelfListenerFSM, one MuteFSM, 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 @@ -1306,6 +1351,7 @@ function wireZebraMachines(){ const call = createFSM(callSpec); const sub = createFSM(subscribeSpec); const selfListener = createFSM(selfListenerSpec); + const mute = createFSM(muteSpec); const pubs = { mic: createFSM(publishSpec), screen: createFSM(publishSpec), @@ -1361,7 +1407,7 @@ function wireZebraMachines(){ } }); - return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft }; + return { call, sub, pubs, selfListener, mute, remoteTiles, tileFor, tileLeft }; } /* ================================================================== @@ -4255,14 +4301,9 @@ async function handleSignal(raw){ * and the mic track stops sending into mesh PCs too. The * user can hit unmute to resume on their own. */ showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn'); - if (!muted){ - muted = true; - try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} - if (micStream){ - for (const t of micStream.getAudioTracks()) t.enabled = false; - } - try { sendMicState(); } catch(_){} - } + /* MuteFSM transition handles track.enabled, sessionStorage, + * sendMicState, and UI refresh via its observer. */ + roomMachines.mute.send('FORCE_MUTE'); renderRoom(); } else { /* Mark the target visually muted right away — their next @@ -4417,12 +4458,11 @@ async function onRoleChanged(prev, next){ if (!canSpeak(prev) && canSpeak(next)){ /* Listener -> speaker (or cohost/host) is a brand-new mic grab. * Default to MUTED so the user doesn't broadcast whatever was - * happening in their room when they accepted. They can click - * 'unmute' when they're ready. ensureMicAndUI() reads the - * sessionStorage value via applyMuteState, so set it BEFORE - * the acquire. */ - muted = true; - try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} + * happening in their room when they accepted. The MuteFSM + * ROLE_PROMOTED event sets ctx.source='self' and the observer + * persists '1' to sessionStorage. ensureMicAndUI's + * applyMuteState then reads the FSM state via muted_get(). */ + roomMachines.mute.send('ROLE_PROMOTED'); /* coming out of listener — tear down every HTTP DJ-mode tap so * the WebRTC audio path takes over (low-latency for conversation). * Also stop the auto-enrol retry loop — speakers don't need it. */ @@ -4446,7 +4486,11 @@ async function onRoleChanged(prev, next){ * row's stream toggle disappears); flip it OFF so its streamMode * entries get torn down cleanly with the rest of our state. */ roomMachines.selfListener.send('DEMOTED'); - dropMic(); muted = false; + dropMic(); + /* Demoted to listener: drop the mute-by-self state — there's + * no mic to broadcast through anymore. The next promote will + * default-mute via ROLE_PROMOTED. */ + roomMachines.mute.send('RESTORE_UNMUTED'); await sfuUnpublish(); await sfuUnpublishScreen(); await sfuUnpublishCamera(); @@ -4920,13 +4964,11 @@ function toggleStreamFor(uuid, pubHex){ * 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; - try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} - applyMuteState(); - try { sendMicState(); } catch(_){} - } + /* auto-mute mic before we start playing the delayed stream — the + * MuteFSM AUTO_MUTE event sets ctx.source='self-listener' so the + * observer chain (applyMuteState + sendMicState + sessionStorage) + * all fires from one place. */ + if (micStream && !muted) roomMachines.mute.send('AUTO_MUTE'); let added = 0; /* INCLUDE self in the enrolment — listeners hear every speaker * (including us), so the canonical "what listeners hear" experience @@ -4961,6 +5003,12 @@ function runSelfListenerDisable(){ /* restore WebRTC playback for every remote */ for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} } roomMachines.selfListener.context.enrolledCount = 0; + /* If the mic was auto-muted by entering self-listener mode, also + * AUTO_UNMUTE so the user is fully back to the live conversation. + * Mute states from other sources (user click, mod) are preserved. */ + if (roomMachines.mute.context.source === 'self-listener'){ + roomMachines.mute.send('AUTO_UNMUTE'); + } renderRoom(); logLine('', 'self-listener OFF — back to live WebRTC mesh'); } @@ -5424,32 +5472,58 @@ $('btn-decline-mic').addEventListener('click', () => { * mute / mic input / music mode — same shape as zebra-audio but the * mute applies to all live senders (we may have many). * ================================================================== */ -let muted = false; -try { muted = sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){} +/* `muted` is now a getter against roomMachines.mute.state — single + * source of truth, no drift possible. Direct writes are no-ops; all + * transitions go through roomMachines.mute.send('TOGGLE' | + * 'FORCE_MUTE' | 'AUTO_MUTE' | 'AUTO_UNMUTE' | 'RESTORE_MUTED' | + * 'RESTORE_UNMUTED' | 'ROLE_PROMOTED'). Side effects (track.enabled, + * btn-mute text/class, member row, peer broadcast) ride the observer + * below. Fox 2026-06-04 directive — every system as an FSM. */ +function muted_get(){ return roomMachines.mute.state === 'off'; } +Object.defineProperty(window, 'muted', { get: muted_get, configurable: true }); +/* Restore from sessionStorage AFTER the FSM exists (the let / load + * at the top of this file ran before roomMachines was built — that + * preceded the FSM era; now the FSM is canonical and the restore + * fires below at the end of room init). */ +try { + const saved = sessionStorage.getItem(MUTE_STATE_KEY); + if (saved === '1') roomMachines.mute.send('RESTORE_MUTED'); + else if (saved === '0') roomMachines.mute.send('RESTORE_UNMUTED'); +} catch(_){} function applyMuteState(){ - /* keep button UI, mic-track enable, room row, and peer broadcast in - * sync. Used by both the click handler and the post-mic-acquire - * restore path (so a hard refresh comes back muted if that's how the - * user left it). Safe to call without a mic — guards each side. */ - if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!muted); } - $('btn-mute').textContent = muted ? 'unmute' : 'mute'; - $('btn-mute').className = muted ? 'invert' : ''; + /* read-only against the FSM state — drives the track + DOM + room + * row. Idempotent; safe to call at any time. The mute-state + * observer below also calls applyMuteState() on every FSM + * transition so external dispatches (e.g. self-listener's + * AUTO_MUTE) get the same UI refresh. */ + const m = muted_get(); + if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!m); } + $('btn-mute').textContent = m ? 'unmute' : 'mute'; + $('btn-mute').className = m ? 'invert' : ''; const mm = members.get(myUUID); - if (mm){ mm.muted = muted; renderRoom(); } + if (mm){ mm.muted = m; renderRoom(); } } +/* Mute-FSM-driven side effects: refresh UI + broadcast mic state + + * persist to sessionStorage on every transition. */ +roomMachines.mute.observe(({ state, prev, ctx, ev }) => { + if (prev === null || state === prev) return; + applyMuteState(); + try { sendMicState(); } catch(_){} + try { sessionStorage.setItem(MUTE_STATE_KEY, state === 'off' ? '1' : '0'); } catch(_){} + logLine('', 'mute: '+prev+' → '+state+(ctx && ctx.source ? ' (source='+ctx.source+')' : '')+(ev && ev.type ? ' ['+ev.type+']' : '')); +}); $('btn-mute').addEventListener('click', () => { if (!micStream) return; - const wasMuted = muted; - muted = !muted; - try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){} - applyMuteState(); - sendMicState(); + const wasMuted = muted_get(); + /* MuteFSM observer handles applyMuteState + sendMicState + + * sessionStorage persist. */ + roomMachines.mute.send('TOGGLE'); /* 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. - * 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'); + * again" — tear down the buffered HTTP streams and restore the + * live WebRTC mesh so the user is back in the now of the + * conversation. SelfListenerFSM's UNMUTE edge handles the + * transition (no-op if already off). */ + if (wasMuted && !muted_get()) roomMachines.selfListener.send('UNMUTE'); }); $('mic-select').addEventListener('change', async (e) => { micDeviceId = e.target.value; @@ -5780,9 +5854,13 @@ $('btn-leave').addEventListener('click', async () => { /* Preserve the user's last mute choice across leave+rejoin — * fox: 'when I leave and rejoin the mic state is unmuted'. Don't * touch MUTE_STATE_KEY here; the next promotion / bless-reclaim - * reads it back via applyMuteState. The local `muted` variable - * still resets so the in-page UI is clean while disconnected. */ - muted = (function(){ try { return sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){ return false; } })(); + * reads it back via the MuteFSM RESTORE_* event. The local mute + * state still resets to whatever sessionStorage said so the + * in-page UI is clean while disconnected. */ + try { + const saved = sessionStorage.getItem(MUTE_STATE_KEY); + roomMachines.mute.send(saved === '1' ? 'RESTORE_MUTED' : 'RESTORE_UNMUTED'); + } catch(_){} logLine('', 'left the space'); }); @@ -5792,8 +5870,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');