zebra-spaces: MuteFSM — lift muted state to a finite state machine

`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.
This commit is contained in:
Russell Ballestrini 2026-06-04 14:13:24 -04:00
parent 10ccea9cb6
commit 7f9d8273c9
No known key found for this signature in database
2 changed files with 132 additions and 53 deletions

View file

@ -35,16 +35,17 @@ const subscribeSpecSrc = extract(/const subscribeSpec = /);
const remoteTileSpecSrc = extract(/const remoteTileSpec = /); const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
const callSpecSrc = extract(/const callSpec = /); const callSpecSrc = extract(/const callSpec = /);
const selfListenerSpecSrc = extract(/const selfListenerSpec = /); const selfListenerSpecSrc = extract(/const selfListenerSpec = /);
const muteSpecSrc = extract(/const muteSpec = /);
const wireMachinesSrc = extract(/function wireZebraMachines\(/); const wireMachinesSrc = extract(/function wireZebraMachines\(/);
/* Function-constructor scope so `const` declarations are visible at the /* Function-constructor scope so `const` declarations are visible at the
* harness's `return` — they would NOT leak through a bare `eval()`. */ * harness's `return` — they would NOT leak through a bare `eval()`. */
const harness = new Function( const harness = new Function(
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + wireMachinesSrc + remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + muteSpecSrc + '\n' + wireMachinesSrc +
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines };' '\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; let pass = 0, fail = 0;
function test(name, fn){ function test(name, fn){

View file

@ -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 * wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), one * SubscribeFSM, three PublishFSMs (mic/screen/camera), one
* SelfListenerFSM, and a Map of RemoteTileFSMs into a coherent * SelfListenerFSM, one MuteFSM, and a Map of RemoteTileFSMs into a
* room. Observers wire transitions between machines; no side * coherent room. Observers wire transitions between machines; no side
* effects in this layer — the page's runtime attaches its OWN * effects in this layer — the page's runtime attaches its OWN
* observers on top to drive actual WebRTC and DOM work. That * observers on top to drive actual WebRTC and DOM work. That
* separation keeps this function fully testable in Node with * separation keeps this function fully testable in Node with
@ -1306,6 +1351,7 @@ function wireZebraMachines(){
const call = createFSM(callSpec); const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec); const sub = createFSM(subscribeSpec);
const selfListener = createFSM(selfListenerSpec); const selfListener = createFSM(selfListenerSpec);
const mute = createFSM(muteSpec);
const pubs = { const pubs = {
mic: createFSM(publishSpec), mic: createFSM(publishSpec),
screen: 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 * and the mic track stops sending into mesh PCs too. The
* user can hit unmute to resume on their own. */ * user can hit unmute to resume on their own. */
showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn'); showNotice('You were muted'+(by?' by '+by.handle:'')+'. Click unmute to talk again.', 'warn');
if (!muted){ /* MuteFSM transition handles track.enabled, sessionStorage,
muted = true; * sendMicState, and UI refresh via its observer. */
try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} roomMachines.mute.send('FORCE_MUTE');
if (micStream){
for (const t of micStream.getAudioTracks()) t.enabled = false;
}
try { sendMicState(); } catch(_){}
}
renderRoom(); renderRoom();
} else { } else {
/* Mark the target visually muted right away — their next /* Mark the target visually muted right away — their next
@ -4417,12 +4458,11 @@ async function onRoleChanged(prev, next){
if (!canSpeak(prev) && canSpeak(next)){ if (!canSpeak(prev) && canSpeak(next)){
/* Listener -> speaker (or cohost/host) is a brand-new mic grab. /* Listener -> speaker (or cohost/host) is a brand-new mic grab.
* Default to MUTED so the user doesn't broadcast whatever was * Default to MUTED so the user doesn't broadcast whatever was
* happening in their room when they accepted. They can click * happening in their room when they accepted. The MuteFSM
* 'unmute' when they're ready. ensureMicAndUI() reads the * ROLE_PROMOTED event sets ctx.source='self' and the observer
* sessionStorage value via applyMuteState, so set it BEFORE * persists '1' to sessionStorage. ensureMicAndUI's
* the acquire. */ * applyMuteState then reads the FSM state via muted_get(). */
muted = true; roomMachines.mute.send('ROLE_PROMOTED');
try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){}
/* coming out of listener — tear down every HTTP DJ-mode tap so /* coming out of listener — tear down every HTTP DJ-mode tap so
* the WebRTC audio path takes over (low-latency for conversation). * the WebRTC audio path takes over (low-latency for conversation).
* Also stop the auto-enrol retry loop — speakers don't need it. */ * 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 * row's stream toggle disappears); flip it OFF so its streamMode
* entries get torn down cleanly with the rest of our state. */ * entries get torn down cleanly with the rest of our state. */
roomMachines.selfListener.send('DEMOTED'); 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 sfuUnpublish();
await sfuUnpublishScreen(); await sfuUnpublishScreen();
await sfuUnpublishCamera(); await sfuUnpublishCamera();
@ -4920,13 +4964,11 @@ function toggleStreamFor(uuid, pubHex){
* below. No internal state mutation, no idempotency guards (the FSM * below. No internal state mutation, no idempotency guards (the FSM
* handles re-entry by never emitting a same-state transition). */ * handles re-entry by never emitting a same-state transition). */
function runSelfListenerEnable(){ function runSelfListenerEnable(){
/* auto-mute mic before we start playing the delayed stream */ /* auto-mute mic before we start playing the delayed stream — the
if (micStream && !muted){ * MuteFSM AUTO_MUTE event sets ctx.source='self-listener' so the
muted = true; * observer chain (applyMuteState + sendMicState + sessionStorage)
try { sessionStorage.setItem(MUTE_STATE_KEY, '1'); } catch(_){} * all fires from one place. */
applyMuteState(); if (micStream && !muted) roomMachines.mute.send('AUTO_MUTE');
try { sendMicState(); } catch(_){}
}
let added = 0; let added = 0;
/* INCLUDE self in the enrolment — listeners hear every speaker /* INCLUDE self in the enrolment — listeners hear every speaker
* (including us), so the canonical "what listeners hear" experience * (including us), so the canonical "what listeners hear" experience
@ -4961,6 +5003,12 @@ function runSelfListenerDisable(){
/* restore WebRTC playback for every remote */ /* restore WebRTC playback for every remote */
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} } for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
roomMachines.selfListener.context.enrolledCount = 0; 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(); renderRoom();
logLine('', 'self-listener OFF — back to live WebRTC mesh'); 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 / mic input / music mode — same shape as zebra-audio but the
* mute applies to all live senders (we may have many). * mute applies to all live senders (we may have many).
* ================================================================== */ * ================================================================== */
let muted = false; /* `muted` is now a getter against roomMachines.mute.state — single
try { muted = sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){} * 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(){ function applyMuteState(){
/* keep button UI, mic-track enable, room row, and peer broadcast in /* read-only against the FSM state — drives the track + DOM + room
* sync. Used by both the click handler and the post-mic-acquire * row. Idempotent; safe to call at any time. The mute-state
* restore path (so a hard refresh comes back muted if that's how the * observer below also calls applyMuteState() on every FSM
* user left it). Safe to call without a mic — guards each side. */ * transition so external dispatches (e.g. self-listener's
if (micStream){ micStream.getAudioTracks().forEach(t=>t.enabled=!muted); } * AUTO_MUTE) get the same UI refresh. */
$('btn-mute').textContent = muted ? 'unmute' : 'mute'; const m = muted_get();
$('btn-mute').className = muted ? 'invert' : ''; 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); 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', () => { $('btn-mute').addEventListener('click', () => {
if (!micStream) return; if (!micStream) return;
const wasMuted = muted; const wasMuted = muted_get();
muted = !muted; /* MuteFSM observer handles applyMuteState + sendMicState +
try { sessionStorage.setItem(MUTE_STATE_KEY, muted ? '1' : '0'); } catch(_){} * sessionStorage persist. */
applyMuteState(); roomMachines.mute.send('TOGGLE');
sendMicState();
/* Unmuting while self-listener-mode is on means "I want to talk /* Unmuting while self-listener-mode is on means "I want to talk
* again" — tear down the buffered HTTP streams and restore the live * again" — tear down the buffered HTTP streams and restore the
* WebRTC mesh so the user is back in the now of the conversation. * live WebRTC mesh so the user is back in the now of the
* The UNMUTE edge on the SelfListenerFSM encodes this — it's a * conversation. SelfListenerFSM's UNMUTE edge handles the
* no-op when already off, drops to off when on. */ * transition (no-op if already off). */
if (wasMuted && !muted) roomMachines.selfListener.send('UNMUTE'); if (wasMuted && !muted_get()) roomMachines.selfListener.send('UNMUTE');
}); });
$('mic-select').addEventListener('change', async (e) => { $('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value; micDeviceId = e.target.value;
@ -5780,9 +5854,13 @@ $('btn-leave').addEventListener('click', async () => {
/* Preserve the user's last mute choice across leave+rejoin — /* Preserve the user's last mute choice across leave+rejoin —
* fox: 'when I leave and rejoin the mic state is unmuted'. Don't * fox: 'when I leave and rejoin the mic state is unmuted'. Don't
* touch MUTE_STATE_KEY here; the next promotion / bless-reclaim * touch MUTE_STATE_KEY here; the next promotion / bless-reclaim
* reads it back via applyMuteState. The local `muted` variable * reads it back via the MuteFSM RESTORE_* event. The local mute
* still resets so the in-page UI is clean while disconnected. */ * state still resets to whatever sessionStorage said so the
muted = (function(){ try { return sessionStorage.getItem(MUTE_STATE_KEY) === '1'; } catch(_){ return false; } })(); * 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'); logLine('', 'left the space');
}); });
@ -5792,8 +5870,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"> <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> <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">019065ca233443b76d222b5a51544179</span><br> md5 <span class="stamp-md5">e1e3c08e99aff562a8434fe4fd943462</span><br>
sha256 <span class="stamp-sha">b4d78f2ff6a88a511abfacce8af816db2c6d235f80e0ebcd154abad002463e27</span><br> sha256 <span class="stamp-sha">1a64b52c3792eed4d8aa4001d9d3347938039f398c3201166ec3bfd99d6c0d12</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">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> <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> </footer>