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 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){

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
* 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');
<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">019065ca233443b76d222b5a51544179</span><br>
sha256 <span class="stamp-sha">b4d78f2ff6a88a511abfacce8af816db2c6d235f80e0ebcd154abad002463e27</span><br>
md5 <span class="stamp-md5">e1e3c08e99aff562a8434fe4fd943462</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">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>