diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index 51e1a7e..8df727a 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -624,19 +624,37 @@ export function initAudioBridge(): void { // ── Audio Control ────────────────────────────────────────────────────── ipcMain.handle('audio:startAudio', (event) => { - // Raw start = user authority (device screen). Resumes any demands the - // last raw stop suspended (§8.3). Plugin callers should migrate to - // the `capture` demand (audio:leases:acquireDemand) — log-once - // telemetry tracks who still comes through here (§6.8). + // Legacy raw start (unmigrated plugins, programmatic paths). While + // the user-stop latch is set this is SUPPRESSED — found in the field: + // nam_tone's keep-alive watchdog restarted the engine 1.5 s after a + // user stop (§8.3). User authority lives on audio:userStartAudio. leaseBridge?.noteLegacyCall(event.sender, 'audio:startAudio'); + if (leaseBridge?.isUserStopLatched()) { + leaseBridge.noteLegacyCall(event.sender, 'audio:startAudio[suppressed-by-user-stop]'); + return; + } audio?.startAudio(); - leaseBridge?.onUserStartAudio(); }); ipcMain.handle('audio:stopAudio', (event) => { - // Raw stop always wins: engine stops, demands suspend (not clear) so - // holders resume on the next user start (§8.3). + // Legacy raw stop: stops the engine but is NOT user authority — the + // device-apply flow and unmigrated plugins use it transiently. The + // user's stop (latch + demand suspension) is audio:userStopAudio. leaseBridge?.noteLegacyCall(event.sender, 'audio:stopAudio'); + audio?.stopAudio(); + }); + + ipcMain.handle('audio:userStartAudio', () => { + // Device screen only: clears the user-stop latch, resumes suspended + // demands, starts the engine (§8.3). + leaseBridge?.onUserStartAudio(); + audio?.startAudio(); + }); + + ipcMain.handle('audio:userStopAudio', () => { + // Device screen only: user stop always wins — latch on, demands + // suspended, engine stopped. Legacy raw starts stay suppressed until + // the user starts again (§8.3). leaseBridge?.onUserStopAudio(); audio?.stopAudio(); }); diff --git a/src/main/lease-bridge.ts b/src/main/lease-bridge.ts index 1a581fd..d847d04 100644 --- a/src/main/lease-bridge.ts +++ b/src/main/lease-bridge.ts @@ -43,6 +43,10 @@ export type LeaseBridge = { // Hooks for the legacy raw channels (§6.8 migration semantics): onUserStartAudio(): void; onUserStopAudio(): void; + // §8.3 user-stop latch: while set, legacy raw startAudio (nam_tone's + // keep-alive watchdog, any unmigrated caller) must be suppressed — only + // an explicit user start clears it. + isUserStopLatched(): boolean; // true = swallow the raw disarm because a demand holder still needs // detection armed (the 6.3 "last disarmer kills a concurrent consumer" fix). shouldIgnoreRawDetectionDisarm(): boolean; @@ -62,9 +66,14 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas // "that telemetry IS the migration progress dashboard"). const legacyCallsLogged = new Set(); // Whether the engine is running because capture demand started it (as - // opposed to a user raw start). Demand-started engines stop when the - // demand drains; user-started engines only stop on user raw stop (§8.3). + // opposed to a user start). Demand-started engines stop when the demand + // drains; user-started engines only stop on user stop (§8.3). let engineStartedByDemand = false; + // User-stop latch (§8.3): the device screen's explicit stop wins over + // EVERYTHING — legacy raw starts and fresh capture demands are held off + // until the user starts again. Found in the field: nam_tone's 1.5 s + // keep-alive watchdog restarted the engine right after a user stop. + let userStopLatch = false; function sanitizeTag(tag: unknown): string | null { if (typeof tag !== 'string' || !TAG_RE.test(tag)) return null; @@ -133,6 +142,9 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas if (!audio) return; if (scope === 'capture') { try { + // A demand arriving while the user has stopped audio does not + // restart the engine — it waits for the user start (§8.3). + if (userStopLatch && active) return; const running = typeof audio.isAudioRunning === 'function' && audio.isAudioRunning() === true; if (active && !running) { audio.startAudio?.(); @@ -197,7 +209,13 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas const holderId = deriveHolder(sender, tag); trackSender(sender, holderId); registry.tryRestore(identityKey(sender, tag), holderId); - return registry.acquireDemand(String(scope), holderId); + const ok = registry.acquireDemand(String(scope), holderId); + // A demand born during a user stop starts suspended, so the user + // start resumes it like every pre-existing demand (§8.3). + if (ok && userStopLatch && String(scope).startsWith('capture')) { + registry.suspendDemands(String(scope)); + } + return ok; }, releaseDemand(sender, scope, tag) { @@ -209,21 +227,28 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas }, onUserStartAudio() { - // User raw start is the only thing that resumes suspended - // demands (§8.3). + // User start is the only thing that clears the stop latch and + // resumes suspended demands (§8.3). + userStopLatch = false; engineStartedByDemand = false; registry.resumeDemands('capture'); registry.resumeDemands('detection:'); }, onUserStopAudio() { - // User raw stop always wins: demands suspend (registration kept), - // holders learn via demand-suspended events (§8.3). + // User stop always wins: the latch holds off legacy raw starts + // and fresh demands; existing demands suspend (registration + // kept), holders learn via demand-suspended events (§8.3). + userStopLatch = true; engineStartedByDemand = false; registry.suspendDemands('capture'); registry.suspendDemands('detection:'); }, + isUserStopLatched() { + return userStopLatch; + }, + shouldIgnoreRawDetectionDisarm() { return anyDetectionActive(); }, diff --git a/src/main/preload.ts b/src/main/preload.ts index c27423a..601a0c3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -247,6 +247,12 @@ const feedBackDesktopApi = { // Audio control startAudio: () => ipcRenderer.invoke('audio:startAudio'), stopAudio: () => ipcRenderer.invoke('audio:stopAudio'), + // User-authority start/stop (device screen only, §8.3): stop latches + // — legacy raw starts and fresh capture demands are held off until + // the user starts again; start clears the latch and resumes + // suspended demands. + userStartAudio: () => ipcRenderer.invoke('audio:userStartAudio'), + userStopAudio: () => ipcRenderer.invoke('audio:userStopAudio'), isAudioRunning: () => ipcRenderer.invoke('audio:isAudioRunning'), // Engine-owned mixer (docs/audio-ownership-plan.md §5.1). Tier 1 diff --git a/src/renderer/screen.js b/src/renderer/screen.js index b28daab..8eea5de 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -965,7 +965,8 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; if (ok) { const inputChannel = parseInt(inputChannelSelect.value); if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel); - await api.startAudio(); + // User-initiated device change: clear any user-stop latch. + await (api.userStartAudio ? api.userStartAudio() : api.startAudio()); audioRunning = true; toggleBtn.textContent = 'Stop'; statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500'; @@ -1523,13 +1524,16 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; // Start/Stop audio toggleBtn.addEventListener('click', async () => { if (audioRunning) { - await api.stopAudio(); + // User authority (§8.3): latches the stop so plugin keep-alive + // watchdogs (nam_tone) and fresh capture demands can't restart + // the engine until the user starts again. + await (api.userStopAudio ? api.userStopAudio() : api.stopAudio()); audioRunning = false; toggleBtn.textContent = 'Start'; statusDot.className = 'w-3 h-3 rounded-full bg-yellow-500'; statusText.textContent = 'Audio stopped'; } else { - await api.startAudio(); + await (api.userStartAudio ? api.userStartAudio() : api.startAudio()); audioRunning = true; toggleBtn.textContent = 'Stop'; statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500'; @@ -1608,7 +1612,8 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel); await api.setMonitorMute(monitorMuteCheckbox.checked); await api.setMonitorKill?.(monitorKillCheckbox.checked); - await api.startAudio(); + // Apply-device is a user action: clear any user-stop latch. + await (api.userStartAudio ? api.userStartAudio() : api.startAudio()); audioRunning = true; toggleBtn.textContent = 'Stop'; statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500'; diff --git a/tests/contracts/ipc-channels.json b/tests/contracts/ipc-channels.json index 0deaf8c..f7d3d75 100644 --- a/tests/contracts/ipc-channels.json +++ b/tests/contracts/ipc-channels.json @@ -117,5 +117,7 @@ "audio:stopAudio", "audio:stopBacking", "audio:unbindInputDevice", + "audio:userStartAudio", + "audio:userStopAudio", "debug:isEnabled" ] diff --git a/tests/contracts/preload-audio-api.json b/tests/contracts/preload-audio-api.json index a5237c1..dc38d01 100644 --- a/tests/contracts/preload-audio-api.json +++ b/tests/contracts/preload-audio-api.json @@ -100,7 +100,9 @@ "startBacking", "stopAudio", "stopBacking", - "unbindInputDevice" + "unbindInputDevice", + "userStartAudio", + "userStopAudio" ], "audioEffects": [ "activateSegment", diff --git a/tests/lease-bridge.test.js b/tests/lease-bridge.test.js index 922d476..e5d0252 100644 --- a/tests/lease-bridge.test.js +++ b/tests/lease-bridge.test.js @@ -100,6 +100,33 @@ test('user stop suspends demands; user start resumes and restarts (§8.3)', () = bridge.dispose(); }); +test('user-stop latch: fresh demands and legacy starts held off until user start (§8.3)', () => { + const audio = fakeAudio(); + const { bridge } = makeBridge(audio); + const tuner = fakeSender(1); + const watchdog = fakeSender(2); + + bridge.onUserStopAudio(); + assert.equal(bridge.isUserStopLatched(), true); + + // nam_tone-style watchdog raw start would be suppressed (the bridge + // exposes the latch; the IPC handler consults it before starting). + assert.equal(bridge.isUserStopLatched(), true); + + // A brand-new capture demand during the latch must NOT start the engine… + bridge.acquireDemand(tuner, 'capture', 'tuner'); + assert.deepEqual(audio.calls, []); + + // …but the user start resumes it like any pre-existing demand. + bridge.onUserStartAudio(); + assert.equal(bridge.isUserStopLatched(), false); + assert.deepEqual(audio.calls, ['start']); + + // Watchdog sender only matters for telemetry attribution; unused here. + void watchdog; + bridge.dispose(); +}); + test('detection demand arms native; raw disarm guarded while demand active (6.3)', () => { const audio = fakeAudio(); const { bridge } = makeBridge(audio);