diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index bd174e4..3c36d0b 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/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');