Revert "fix(audio): user-stop latch - plugin keep-alives can no longer undo a user stop"

This reverts commit bb0f79e0be.
This commit is contained in:
OmikronApex
2026-07-15 01:54:55 +02:00
parent 1d73c7080b
commit 08c120e450
7 changed files with 19 additions and 104 deletions
+7 -25
View File
@@ -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();
});
+7 -32
View File
@@ -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<string>();
// 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();
},
-6
View File
@@ -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
+4 -9
View File
@@ -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';
-2
View File
@@ -117,7 +117,5 @@
"audio:stopAudio",
"audio:stopBacking",
"audio:unbindInputDevice",
"audio:userStartAudio",
"audio:userStopAudio",
"debug:isEnabled"
]
+1 -3
View File
@@ -100,9 +100,7 @@
"startBacking",
"stopAudio",
"stopBacking",
"unbindInputDevice",
"userStartAudio",
"userStopAudio"
"unbindInputDevice"
],
"audioEffects": [
"activateSegment",
-27
View File
@@ -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);