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:
parent
10ccea9cb6
commit
7f9d8273c9
2 changed files with 132 additions and 53 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue