diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index 8df727a..51e1a7e 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -624,37 +624,19 @@ export function initAudioBridge(): void { // ── Audio Control ────────────────────────────────────────────────────── ipcMain.handle('audio:startAudio', (event) => { - // 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. + // 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). 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) => { - // 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. + // Raw stop always wins: engine stops, demands suspend (not clear) so + // holders resume on the next user start (§8.3). 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 d847d04..1a581fd 100644 --- a/src/main/lease-bridge.ts +++ b/src/main/lease-bridge.ts @@ -43,10 +43,6 @@ 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; @@ -66,14 +62,9 @@ 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 start). Demand-started engines stop when the demand - // drains; user-started engines only stop on user stop (§8.3). + // 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). 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; @@ -142,9 +133,6 @@ 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?.(); @@ -209,13 +197,7 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas const holderId = deriveHolder(sender, tag); trackSender(sender, holderId); registry.tryRestore(identityKey(sender, tag), 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; + return registry.acquireDemand(String(scope), holderId); }, releaseDemand(sender, scope, tag) { @@ -227,28 +209,21 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas }, onUserStartAudio() { - // User start is the only thing that clears the stop latch and - // resumes suspended demands (§8.3). - userStopLatch = false; + // User raw start is the only thing that resumes suspended + // demands (§8.3). engineStartedByDemand = false; registry.resumeDemands('capture'); registry.resumeDemands('detection:'); }, onUserStopAudio() { - // 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; + // User raw stop always wins: demands suspend (registration kept), + // holders learn via demand-suspended events (§8.3). 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 601a0c3..c27423a 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -247,12 +247,6 @@ 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 8eea5de..b28daab 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -965,8 +965,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; if (ok) { const inputChannel = parseInt(inputChannelSelect.value); if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel); - // User-initiated device change: clear any user-stop latch. - await (api.userStartAudio ? api.userStartAudio() : api.startAudio()); + await api.startAudio(); audioRunning = true; toggleBtn.textContent = 'Stop'; statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500'; @@ -1524,16 +1523,13 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; // Start/Stop audio toggleBtn.addEventListener('click', async () => { if (audioRunning) { - // 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()); + await 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.userStartAudio ? api.userStartAudio() : api.startAudio()); + await api.startAudio(); audioRunning = true; toggleBtn.textContent = 'Stop'; statusDot.className = 'w-3 h-3 rounded-full bg-emerald-500'; @@ -1612,8 +1608,7 @@ window.__feedBackDesktopAudioHooks = window.__feedBackDesktopAudioHooks || {}; if (Number.isFinite(inputChannel)) await api.setInputChannel(inputChannel); await api.setMonitorMute(monitorMuteCheckbox.checked); await api.setMonitorKill?.(monitorKillCheckbox.checked); - // Apply-device is a user action: clear any user-stop latch. - await (api.userStartAudio ? api.userStartAudio() : api.startAudio()); + await 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 f7d3d75..0deaf8c 100644 --- a/tests/contracts/ipc-channels.json +++ b/tests/contracts/ipc-channels.json @@ -117,7 +117,5 @@ "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 dc38d01..a5237c1 100644 --- a/tests/contracts/preload-audio-api.json +++ b/tests/contracts/preload-audio-api.json @@ -100,9 +100,7 @@ "startBacking", "stopAudio", "stopBacking", - "unbindInputDevice", - "userStartAudio", - "userStopAudio" + "unbindInputDevice" ], "audioEffects": [ "activateSegment", diff --git a/tests/lease-bridge.test.js b/tests/lease-bridge.test.js index e5d0252..922d476 100644 --- a/tests/lease-bridge.test.js +++ b/tests/lease-bridge.test.js @@ -100,33 +100,6 @@ 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);