zebra-spaces: formalize self-listener as FSM — pure spec + observer-driven side effects + 12 unit tests
Fox 2026-06-04 directive: every system should be a state machine with unit + integration + functional test coverage. Implicit-state defects keep biting (kicked-listener-UI-still-green, two-kick race, cohost-toggle-kills-phone, audio-wedge-no-recovery). Starting the formalization with the most-broken-today system: self-listener mode. Spec (selfListenerSpec): off ──ENABLE / TOGGLE──▶ on on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off Sits next to publishSpec, subscribeSpec, callSpec, remoteTileSpec in zebra-spaces.html. Composed by wireZebraMachines() into roomMachines.selfListener. UNMUTE edge encodes fox's invariant: "unmuting should seamlessly switch them back to the now of the conversation webrtc mesh" — if the user clicks unmute while on, they implicitly drop back to off. Side effects (mic mute, streamMode enrolment, remoteAudio muting) move out of enableSelfListenerMode/disableSelfListenerMode (deleted) into runSelfListenerEnable / runSelfListenerDisable, called by an observer attached to the FSM. Pure spec stays Node-testable; the runtime drives the actual audio plumbing from observed transitions. Boolean selfListenerMode flag deleted. window.selfListenerMode is now a getter against the FSM state — single source of truth, no drift possible. All callers (toggle-button click, mute-unmute, peer-joined, role-demote, leave) now dispatch FSM events instead of calling helpers directly. Tests in test/self-listener-fsm.test.js: - starts in off - TOGGLE / ENABLE / DISABLE transitions - UNMUTE drops to off (the fox-invariant) - UNMUTE / CLEAR while off is no-op - DEMOTED drops to off - CLEAR drops to off - unknown event refuses - observer fires on real transitions with prev/state - runtime observer skips prev===state edges Existing test/zebra-fsm.test.js updated to extract+expose selfListenerSpec alongside the other specs (the wireZebraMachines extract is the integration test). Makefile gets test-self-listener target + slot in test-all. All test suites green: - self-listener: 12 / 12 - zebra-fsm: 83 / 83 - mod-actions: 6 / 6 - web-protocol: 3348 / 3348 - multi-peer-mesh: 8 / 8 - video-track-removal: 18 / 18
This commit is contained in:
parent
d71a37f84a
commit
f4dbc5cc6c
4 changed files with 251 additions and 38 deletions
|
|
@ -1248,18 +1248,61 @@ const callSpec = {
|
|||
};
|
||||
|
||||
/* ==================================================================
|
||||
* wireZebraMachines — orchestrator. Composes one CallFSM, one
|
||||
* SubscribeFSM, three PublishFSMs (mic/screen/camera), 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 synthetic events.
|
||||
* SelfListenerFSM — a speaker / cohost / host who's flipped their
|
||||
* own row's stream toggle to consume the room via the buffered HTTP
|
||||
* Ogg/Opus path instead of the live WebRTC mesh.
|
||||
*
|
||||
* Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */
|
||||
* off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off
|
||||
*
|
||||
* The UNMUTE edge encodes fox's invariant: "unmuting should
|
||||
* seamlessly switch them back to the now of the conversation
|
||||
* webrtc mesh" — if the user clicks unmute while in on, they
|
||||
* implicitly want to go back to the live path.
|
||||
*
|
||||
* Pure spec — side effects (mic mute, streamMode population,
|
||||
* remoteAudio muting) live in the runtime's observer attached to
|
||||
* this FSM. Keeps it testable in Node. */
|
||||
const selfListenerSpec = {
|
||||
initial: 'off',
|
||||
context: { enrolledCount: 0 },
|
||||
states: {
|
||||
off: {
|
||||
on: {
|
||||
TOGGLE: 'on',
|
||||
ENABLE: 'on',
|
||||
UNMUTE: 'off', /* no-op self-transition for symmetry */
|
||||
DEMOTED: 'off',
|
||||
CLEAR: 'off',
|
||||
},
|
||||
},
|
||||
on: {
|
||||
on: {
|
||||
TOGGLE: 'off',
|
||||
DISABLE: 'off',
|
||||
UNMUTE: 'off',
|
||||
DEMOTED: 'off',
|
||||
CLEAR: 'off',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/* ==================================================================
|
||||
* 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
|
||||
* 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
|
||||
* synthetic events.
|
||||
*
|
||||
* Returns { call, sub, pubs, selfListener, remoteTiles, tileFor,
|
||||
* tileLeft }. */
|
||||
function wireZebraMachines(){
|
||||
const call = createFSM(callSpec);
|
||||
const sub = createFSM(subscribeSpec);
|
||||
const selfListener = createFSM(selfListenerSpec);
|
||||
const pubs = {
|
||||
mic: createFSM(publishSpec),
|
||||
screen: createFSM(publishSpec),
|
||||
|
|
@ -1315,7 +1358,7 @@ function wireZebraMachines(){
|
|||
}
|
||||
});
|
||||
|
||||
return { call, sub, pubs, remoteTiles, tileFor, tileLeft };
|
||||
return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft };
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
|
|
@ -4172,7 +4215,7 @@ async function onRoleChanged(prev, next){
|
|||
/* self-listener flag is meaningless once we're a listener (the
|
||||
* row's stream toggle disappears); flip it OFF so its streamMode
|
||||
* entries get torn down cleanly with the rest of our state. */
|
||||
if (selfListenerMode) disableSelfListenerMode();
|
||||
roomMachines.selfListener.send('DEMOTED');
|
||||
dropMic(); muted = false;
|
||||
await sfuUnpublish();
|
||||
await sfuUnpublishScreen();
|
||||
|
|
@ -4492,7 +4535,14 @@ let listenerOutputMuted = true;
|
|||
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
|
||||
* they can't talk into a delayed stream (they'd be 2-4s behind the
|
||||
* conversation); unmuting toggles them back to WebRTC seamlessly. */
|
||||
let selfListenerMode = false;
|
||||
/* selfListenerMode is now a derived getter against the SelfListenerFSM
|
||||
* (roomMachines.selfListener). Single source of truth — direct writes
|
||||
* to the boolean would drift from the FSM state, defeating the point.
|
||||
* All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' |
|
||||
* 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer
|
||||
* attached during room setup (see selfListenerObserver below). */
|
||||
function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; }
|
||||
Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get });
|
||||
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
|
||||
* phones — Firefox Android refuses autoplay on every fresh <audio>
|
||||
* with src URL and a hard refresh starts the same loop. WebRTC stays
|
||||
|
|
@ -4636,9 +4686,10 @@ function toggleStreamFor(uuid, pubHex){
|
|||
* mute the corresponding remoteAudio (WebRTC) elements so we don't
|
||||
* hear both paths at once. disable* tears everything down + restores
|
||||
* the WebRTC playback. */
|
||||
async function enableSelfListenerMode(){
|
||||
if (selfListenerMode) return;
|
||||
selfListenerMode = true;
|
||||
/* Side-effect runners — invoked ONLY by the SelfListenerFSM observer
|
||||
* 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;
|
||||
|
|
@ -4659,20 +4710,16 @@ async function enableSelfListenerMode(){
|
|||
if (streamMode.has(pubHex)) continue;
|
||||
streamMode.add(pubHex);
|
||||
startStream(uuid, pubHex);
|
||||
/* mute the matched WebRTC remote so we don't hear both paths.
|
||||
* Self has no remoteAudio entry (we never subscribe to ourselves
|
||||
* via SFU — see the self-echo skip in handleRemoteSfuTrack), so
|
||||
* this is a no-op for the self row. */
|
||||
/* mute the matched WebRTC remote so we don't hear both paths. */
|
||||
const w = remoteAudio.get(uuid);
|
||||
if (w) try { w.muted = true; } catch(_){}
|
||||
added++;
|
||||
}
|
||||
roomMachines.selfListener.context.enrolledCount = added;
|
||||
renderRoom();
|
||||
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
|
||||
}
|
||||
function disableSelfListenerMode(){
|
||||
if (!selfListenerMode) return;
|
||||
selfListenerMode = false;
|
||||
function runSelfListenerDisable(){
|
||||
for (const pubHex of [...streamMode]){
|
||||
let foundUuid = null;
|
||||
for (const [u, mm] of members){
|
||||
|
|
@ -4681,17 +4728,18 @@ function disableSelfListenerMode(){
|
|||
streamMode.delete(pubHex);
|
||||
if (foundUuid) stopStream(foundUuid);
|
||||
}
|
||||
/* restore WebRTC playback for every remote — stopStream already
|
||||
* unmutes the matched uuid; this catches any others we couldn't
|
||||
* resolve (e.g. member dropped while we were in DJ mode). */
|
||||
/* restore WebRTC playback for every remote */
|
||||
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
|
||||
roomMachines.selfListener.context.enrolledCount = 0;
|
||||
renderRoom();
|
||||
logLine('', 'self-listener OFF — back to live WebRTC mesh');
|
||||
}
|
||||
function toggleSelfListenerMode(){
|
||||
if (selfListenerMode) disableSelfListenerMode();
|
||||
else enableSelfListenerMode();
|
||||
}
|
||||
/* Observer: side effects fire on every off↔on transition. */
|
||||
roomMachines.selfListener.observe(({ state, prev }) => {
|
||||
if (prev === null || state === prev) return;
|
||||
if (state === 'on') runSelfListenerEnable();
|
||||
else runSelfListenerDisable();
|
||||
});
|
||||
|
||||
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
|
||||
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
|
||||
|
|
@ -4818,7 +4866,7 @@ function renderRoom(){
|
|||
streamEl.title = selfListenerMode
|
||||
? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
|
||||
: 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic';
|
||||
streamEl.onclick = () => toggleSelfListenerMode();
|
||||
streamEl.onclick = () => roomMachines.selfListener.send('TOGGLE');
|
||||
}
|
||||
if (isMod(myRole) && m.uuid !== myUUID){
|
||||
if (m.role === 'listener'){
|
||||
|
|
@ -5161,8 +5209,10 @@ $('btn-mute').addEventListener('click', () => {
|
|||
sendMicState();
|
||||
/* 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. */
|
||||
if (wasMuted && !muted && selfListenerMode) disableSelfListenerMode();
|
||||
* 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');
|
||||
});
|
||||
$('mic-select').addEventListener('change', async (e) => {
|
||||
micDeviceId = e.target.value;
|
||||
|
|
@ -5452,7 +5502,7 @@ $('btn-leave').addEventListener('click', async () => {
|
|||
}
|
||||
streamAudio.clear();
|
||||
streamMode.clear();
|
||||
selfListenerMode = false;
|
||||
roomMachines.selfListener.send('CLEAR');
|
||||
for (const u of [...peers.keys()]) tearPeer(u);
|
||||
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
|
||||
* leave (user clicked leave / closed tab) from a hiccup disconnect
|
||||
|
|
@ -5502,8 +5552,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">0c3035d2de972b24edd97e8b6130f1f3</span><br>
|
||||
sha256 <span class="stamp-sha">7aa8d55df2a764566d1b1af276961c46109189f992b357a00b6201be94559339</span><br>
|
||||
md5 <span class="stamp-md5">12a9280b3fcf75a793ad9b6a1b0ebcd1</span><br>
|
||||
sha256 <span class="stamp-sha">1562148740ec1d0ca0211574d03f5670757f86c663abd98aee47f951eddc3c48</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