zebra-report: deploy self-listener FSM (state-machine driven)
This commit is contained in:
parent
c578779398
commit
0983639b24
1 changed files with 84 additions and 34 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