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
12
Makefile
12
Makefile
|
|
@ -53,6 +53,14 @@ test-mesh:
|
||||||
test-mod-actions:
|
test-mod-actions:
|
||||||
@node test/mod-action-serializer.test.js
|
@node test/mod-action-serializer.test.js
|
||||||
|
|
||||||
|
# SelfListenerFSM — pins the state-machine contract for the speaker/
|
||||||
|
# cohost/host "switch myself to the buffered HTTP listener stream"
|
||||||
|
# toggle. Extracts createFSM + selfListenerSpec from the live page so
|
||||||
|
# the spec can't drift from shipped transitions (off↔on with
|
||||||
|
# TOGGLE/ENABLE/DISABLE/UNMUTE/DEMOTED/CLEAR edges).
|
||||||
|
test-self-listener:
|
||||||
|
@node test/self-listener-fsm.test.js
|
||||||
|
|
||||||
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
|
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
|
||||||
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
|
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
|
||||||
# alongside this checkout AND has a Go toolchain — we build the relay binary
|
# alongside this checkout AND has a Go toolchain — we build the relay binary
|
||||||
|
|
@ -70,7 +78,7 @@ test-zebra-spaces:
|
||||||
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
|
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
|
||||||
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
|
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
|
||||||
|
|
||||||
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-zebra-spaces
|
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-zebra-spaces
|
||||||
@echo "--- unit ---"
|
@echo "--- unit ---"
|
||||||
@./test/unit
|
@./test/unit
|
||||||
@echo "--- integration ---"
|
@echo "--- integration ---"
|
||||||
|
|
@ -87,6 +95,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide
|
||||||
@node test/multi-peer-mesh.test.js
|
@node test/multi-peer-mesh.test.js
|
||||||
@echo "--- mod-action serializer ---"
|
@echo "--- mod-action serializer ---"
|
||||||
@node test/mod-action-serializer.test.js
|
@node test/mod-action-serializer.test.js
|
||||||
|
@echo "--- self-listener FSM ---"
|
||||||
|
@node test/self-listener-fsm.test.js
|
||||||
@echo "--- zebra-spaces ---"
|
@echo "--- zebra-spaces ---"
|
||||||
@$(MAKE) -s test-zebra-spaces
|
@$(MAKE) -s test-zebra-spaces
|
||||||
|
|
||||||
|
|
|
||||||
152
test/self-listener-fsm.test.js
Normal file
152
test/self-listener-fsm.test.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/* SelfListenerFSM tests — pins the state-machine contract for the
|
||||||
|
* speaker/cohost/host "switch myself to the buffered HTTP listener
|
||||||
|
* stream" toggle that fox introduced 2026-06-04. Driven by extracted
|
||||||
|
* source from web/zebra-spaces.html so the spec can't drift from
|
||||||
|
* shipped behavior.
|
||||||
|
*
|
||||||
|
* node test/self-listener-fsm.test.js
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
|
||||||
|
|
||||||
|
function extract(re){
|
||||||
|
const m = src.match(re);
|
||||||
|
if (!m) throw new Error('could not find ' + re);
|
||||||
|
let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i;
|
||||||
|
for (; j < src.length; j++){
|
||||||
|
if (src[j] === '{') depth++;
|
||||||
|
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
|
||||||
|
}
|
||||||
|
if (src[j] === ';') j++;
|
||||||
|
return src.slice(m.index, j);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFSMSrc = extract(/function createFSM\(/);
|
||||||
|
const selfListenerSpecSrc = extract(/const selfListenerSpec = /);
|
||||||
|
|
||||||
|
const harness = new Function(
|
||||||
|
createFSMSrc + '\n' + selfListenerSpecSrc + '\nreturn { createFSM, selfListenerSpec };'
|
||||||
|
);
|
||||||
|
const { createFSM, selfListenerSpec } = harness();
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function test(name, fn){
|
||||||
|
try { fn(); console.log(' ✓ ' + name); pass++; }
|
||||||
|
catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; }
|
||||||
|
}
|
||||||
|
function eq(a, b, msg){
|
||||||
|
if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b));
|
||||||
|
}
|
||||||
|
function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); }
|
||||||
|
|
||||||
|
console.log('SelfListenerFSM:');
|
||||||
|
|
||||||
|
test('starts in off state', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial enrolledCount is 0', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
eq(m.context.enrolledCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TOGGLE off→on, then off→on→off', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
truthy(m.send('TOGGLE'));
|
||||||
|
eq(m.state, 'on');
|
||||||
|
truthy(m.send('TOGGLE'));
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ENABLE off→on; DISABLE on→off', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
truthy(m.send('ENABLE'));
|
||||||
|
eq(m.state, 'on');
|
||||||
|
truthy(m.send('DISABLE'));
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UNMUTE while on drops to off (fox: "unmuting should seamlessly switch back")', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
m.send('ENABLE');
|
||||||
|
eq(m.state, 'on');
|
||||||
|
truthy(m.send('UNMUTE'));
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UNMUTE while off is a no-op self-transition (still off)', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
// self-transitions still count as "transitioned"; the spec defines
|
||||||
|
// them so the runtime can observe the event without surprise.
|
||||||
|
m.send('UNMUTE');
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DEMOTED while on drops to off (role transition listener)', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
m.send('ENABLE');
|
||||||
|
truthy(m.send('DEMOTED'));
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLEAR while on drops to off (leave room)', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
m.send('ENABLE');
|
||||||
|
truthy(m.send('CLEAR'));
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLEAR while off is a no-op (idempotent on leave)', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
m.send('CLEAR');
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown event refuses transition', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
eq(m.send('NOPE'), false);
|
||||||
|
eq(m.state, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observer fires on every real transition with prev/state', () => {
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
const seen = [];
|
||||||
|
m.observe(({ state, prev, ev }) => seen.push({ state, prev, ev: ev && ev.type }));
|
||||||
|
m.send('TOGGLE');
|
||||||
|
m.send('TOGGLE');
|
||||||
|
m.send('TOGGLE');
|
||||||
|
// first observer call is the "started" notification (prev=null, ev=null)
|
||||||
|
// subsequent calls are actual transitions.
|
||||||
|
const real = seen.filter(s => s.ev !== null);
|
||||||
|
eq(real.length, 3);
|
||||||
|
eq(real[0].prev, 'off'); eq(real[0].state, 'on'); eq(real[0].ev, 'TOGGLE');
|
||||||
|
eq(real[1].prev, 'on'); eq(real[1].state, 'off'); eq(real[1].ev, 'TOGGLE');
|
||||||
|
eq(real[2].prev, 'off'); eq(real[2].state, 'on'); eq(real[2].ev, 'TOGGLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime observer pattern: prev===state edges (UNMUTE while off) skipped by guard', () => {
|
||||||
|
/* The runtime observer (in zebra-spaces.html) skips transitions
|
||||||
|
* where state === prev to avoid spurious side-effect fires. This
|
||||||
|
* test pins the convention: UNMUTE while off DOES emit an event,
|
||||||
|
* but with state===prev so the runtime guard filters it. */
|
||||||
|
const m = createFSM(selfListenerSpec);
|
||||||
|
const realTransitions = [];
|
||||||
|
m.observe(({ state, prev }) => {
|
||||||
|
if (prev === null || state === prev) return; // matches runtime guard
|
||||||
|
realTransitions.push({ from: prev, to: state });
|
||||||
|
});
|
||||||
|
m.send('UNMUTE'); // off → off, filtered out
|
||||||
|
m.send('ENABLE'); // off → on, kept
|
||||||
|
m.send('UNMUTE'); // on → off, kept
|
||||||
|
m.send('CLEAR'); // off → off, filtered out
|
||||||
|
eq(realTransitions.length, 2);
|
||||||
|
eq(realTransitions[0].from, 'off'); eq(realTransitions[0].to, 'on');
|
||||||
|
eq(realTransitions[1].from, 'on'); eq(realTransitions[1].to, 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('passed: ' + pass + ' failed: ' + fail);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
|
@ -34,16 +34,17 @@ const publishSpecSrc = extract(/const publishSpec = /);
|
||||||
const subscribeSpecSrc = extract(/const subscribeSpec = /);
|
const subscribeSpecSrc = extract(/const subscribeSpec = /);
|
||||||
const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
|
const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
|
||||||
const callSpecSrc = extract(/const callSpec = /);
|
const callSpecSrc = extract(/const callSpec = /);
|
||||||
|
const selfListenerSpecSrc = extract(/const selfListenerSpec = /);
|
||||||
const wireMachinesSrc = extract(/function wireZebraMachines\(/);
|
const wireMachinesSrc = extract(/function wireZebraMachines\(/);
|
||||||
|
|
||||||
/* Function-constructor scope so `const` declarations are visible at the
|
/* Function-constructor scope so `const` declarations are visible at the
|
||||||
* harness's `return` — they would NOT leak through a bare `eval()`. */
|
* harness's `return` — they would NOT leak through a bare `eval()`. */
|
||||||
const harness = new Function(
|
const harness = new Function(
|
||||||
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
|
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
|
||||||
remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + wireMachinesSrc +
|
remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + wireMachinesSrc +
|
||||||
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines };'
|
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines };'
|
||||||
);
|
);
|
||||||
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines } = harness();
|
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines } = harness();
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
let pass = 0, fail = 0;
|
||||||
function test(name, fn){
|
function test(name, fn){
|
||||||
|
|
|
||||||
|
|
@ -1248,18 +1248,61 @@ const callSpec = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ==================================================================
|
/* ==================================================================
|
||||||
* wireZebraMachines — orchestrator. Composes one CallFSM, one
|
* SelfListenerFSM — a speaker / cohost / host who's flipped their
|
||||||
* SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of
|
* own row's stream toggle to consume the room via the buffered HTTP
|
||||||
* RemoteTileFSMs into a coherent room. Observers wire transitions
|
* Ogg/Opus path instead of the live WebRTC mesh.
|
||||||
* 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, 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(){
|
function wireZebraMachines(){
|
||||||
const call = createFSM(callSpec);
|
const call = createFSM(callSpec);
|
||||||
const sub = createFSM(subscribeSpec);
|
const sub = createFSM(subscribeSpec);
|
||||||
|
const selfListener = createFSM(selfListenerSpec);
|
||||||
const pubs = {
|
const pubs = {
|
||||||
mic: createFSM(publishSpec),
|
mic: createFSM(publishSpec),
|
||||||
screen: 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
|
/* self-listener flag is meaningless once we're a listener (the
|
||||||
* row's stream toggle disappears); flip it OFF so its streamMode
|
* row's stream toggle disappears); flip it OFF so its streamMode
|
||||||
* entries get torn down cleanly with the rest of our state. */
|
* entries get torn down cleanly with the rest of our state. */
|
||||||
if (selfListenerMode) disableSelfListenerMode();
|
roomMachines.selfListener.send('DEMOTED');
|
||||||
dropMic(); muted = false;
|
dropMic(); muted = false;
|
||||||
await sfuUnpublish();
|
await sfuUnpublish();
|
||||||
await sfuUnpublishScreen();
|
await sfuUnpublishScreen();
|
||||||
|
|
@ -4492,7 +4535,14 @@ let listenerOutputMuted = true;
|
||||||
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
|
* 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
|
* they can't talk into a delayed stream (they'd be 2-4s behind the
|
||||||
* conversation); unmuting toggles them back to WebRTC seamlessly. */
|
* 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
|
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
|
||||||
* phones — Firefox Android refuses autoplay on every fresh <audio>
|
* phones — Firefox Android refuses autoplay on every fresh <audio>
|
||||||
* with src URL and a hard refresh starts the same loop. WebRTC stays
|
* 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
|
* mute the corresponding remoteAudio (WebRTC) elements so we don't
|
||||||
* hear both paths at once. disable* tears everything down + restores
|
* hear both paths at once. disable* tears everything down + restores
|
||||||
* the WebRTC playback. */
|
* the WebRTC playback. */
|
||||||
async function enableSelfListenerMode(){
|
/* Side-effect runners — invoked ONLY by the SelfListenerFSM observer
|
||||||
if (selfListenerMode) return;
|
* below. No internal state mutation, no idempotency guards (the FSM
|
||||||
selfListenerMode = true;
|
* handles re-entry by never emitting a same-state transition). */
|
||||||
|
function runSelfListenerEnable(){
|
||||||
/* auto-mute mic before we start playing the delayed stream */
|
/* auto-mute mic before we start playing the delayed stream */
|
||||||
if (micStream && !muted){
|
if (micStream && !muted){
|
||||||
muted = true;
|
muted = true;
|
||||||
|
|
@ -4659,20 +4710,16 @@ async function enableSelfListenerMode(){
|
||||||
if (streamMode.has(pubHex)) continue;
|
if (streamMode.has(pubHex)) continue;
|
||||||
streamMode.add(pubHex);
|
streamMode.add(pubHex);
|
||||||
startStream(uuid, pubHex);
|
startStream(uuid, pubHex);
|
||||||
/* mute the matched WebRTC remote so we don't hear both paths.
|
/* 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. */
|
|
||||||
const w = remoteAudio.get(uuid);
|
const w = remoteAudio.get(uuid);
|
||||||
if (w) try { w.muted = true; } catch(_){}
|
if (w) try { w.muted = true; } catch(_){}
|
||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
|
roomMachines.selfListener.context.enrolledCount = added;
|
||||||
renderRoom();
|
renderRoom();
|
||||||
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
|
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
|
||||||
}
|
}
|
||||||
function disableSelfListenerMode(){
|
function runSelfListenerDisable(){
|
||||||
if (!selfListenerMode) return;
|
|
||||||
selfListenerMode = false;
|
|
||||||
for (const pubHex of [...streamMode]){
|
for (const pubHex of [...streamMode]){
|
||||||
let foundUuid = null;
|
let foundUuid = null;
|
||||||
for (const [u, mm] of members){
|
for (const [u, mm] of members){
|
||||||
|
|
@ -4681,17 +4728,18 @@ function disableSelfListenerMode(){
|
||||||
streamMode.delete(pubHex);
|
streamMode.delete(pubHex);
|
||||||
if (foundUuid) stopStream(foundUuid);
|
if (foundUuid) stopStream(foundUuid);
|
||||||
}
|
}
|
||||||
/* restore WebRTC playback for every remote — stopStream already
|
/* restore WebRTC playback for every remote */
|
||||||
* unmutes the matched uuid; this catches any others we couldn't
|
|
||||||
* resolve (e.g. member dropped while we were in DJ mode). */
|
|
||||||
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
|
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
|
||||||
|
roomMachines.selfListener.context.enrolledCount = 0;
|
||||||
renderRoom();
|
renderRoom();
|
||||||
logLine('', 'self-listener OFF — back to live WebRTC mesh');
|
logLine('', 'self-listener OFF — back to live WebRTC mesh');
|
||||||
}
|
}
|
||||||
function toggleSelfListenerMode(){
|
/* Observer: side effects fire on every off↔on transition. */
|
||||||
if (selfListenerMode) disableSelfListenerMode();
|
roomMachines.selfListener.observe(({ state, prev }) => {
|
||||||
else enableSelfListenerMode();
|
if (prev === null || state === prev) return;
|
||||||
}
|
if (state === 'on') runSelfListenerEnable();
|
||||||
|
else runSelfListenerDisable();
|
||||||
|
});
|
||||||
|
|
||||||
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
|
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path
|
||||||
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
|
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's
|
||||||
|
|
@ -4818,7 +4866,7 @@ function renderRoom(){
|
||||||
streamEl.title = selfListenerMode
|
streamEl.title = selfListenerMode
|
||||||
? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
|
? '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';
|
: '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 (isMod(myRole) && m.uuid !== myUUID){
|
||||||
if (m.role === 'listener'){
|
if (m.role === 'listener'){
|
||||||
|
|
@ -5161,8 +5209,10 @@ $('btn-mute').addEventListener('click', () => {
|
||||||
sendMicState();
|
sendMicState();
|
||||||
/* Unmuting while self-listener-mode is on means "I want to talk
|
/* Unmuting while self-listener-mode is on means "I want to talk
|
||||||
* again" — tear down the buffered HTTP streams and restore the live
|
* again" — tear down the buffered HTTP streams and restore the live
|
||||||
* WebRTC mesh so the user is back in the now of the conversation. */
|
* WebRTC mesh so the user is back in the now of the conversation.
|
||||||
if (wasMuted && !muted && selfListenerMode) disableSelfListenerMode();
|
* 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) => {
|
$('mic-select').addEventListener('change', async (e) => {
|
||||||
micDeviceId = e.target.value;
|
micDeviceId = e.target.value;
|
||||||
|
|
@ -5452,7 +5502,7 @@ $('btn-leave').addEventListener('click', async () => {
|
||||||
}
|
}
|
||||||
streamAudio.clear();
|
streamAudio.clear();
|
||||||
streamMode.clear();
|
streamMode.clear();
|
||||||
selfListenerMode = false;
|
roomMachines.selfListener.send('CLEAR');
|
||||||
for (const u of [...peers.keys()]) tearPeer(u);
|
for (const u of [...peers.keys()]) tearPeer(u);
|
||||||
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
|
/* send 'bye' BEFORE closing the WS — server distinguishes a strong
|
||||||
* leave (user clicked leave / closed tab) from a hiccup disconnect
|
* 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">
|
<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>
|
<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>
|
md5 <span class="stamp-md5">12a9280b3fcf75a793ad9b6a1b0ebcd1</span><br>
|
||||||
sha256 <span class="stamp-sha">7aa8d55df2a764566d1b1af276961c46109189f992b357a00b6201be94559339</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue