mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-22 21:02:05 +00:00
fix(audio): user-stop latch - plugin keep-alives can no longer undo a user stop
Field finding (fix14 test build): nam_tone's 1.5s keep-alive watchdog calls raw startAudio whenever the engine is not running, restarting it seconds after the user pressed Stop - and the shim treated any raw start as user authority, resuming all suspended demands with it. Per plan 8.3 (user stop always wins) raw start/stop was supposed to be device-screen-only; now enforced: - audio:userStartAudio / audio:userStopAudio: explicit user authority. Stop sets a latch (+ suspends demands); start clears it (+ resumes). - raw audio:startAudio is SUPPRESSED while latched (log-once telemetry 'suppressed-by-user-stop') and no longer resumes demands. - raw audio:stopAudio no longer suspends demands (it is the transient stop the device-apply flow and unmigrated plugins use). - fresh capture demands born during the latch start suspended, so the user start resumes them like pre-existing ones; the demand glue also refuses to start the engine while latched. - device screen (src/renderer/screen.js) toggle + apply flows use the user-authority calls. Contract snapshots regenerated (2 IPC channels, 2 preload keys). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
668aff98fe
commit
bb0f79e0be
@ -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();
|
||||
});
|
||||
|
||||
@ -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<string>();
|
||||
// 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();
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -117,5 +117,7 @@
|
||||
"audio:stopAudio",
|
||||
"audio:stopBacking",
|
||||
"audio:unbindInputDevice",
|
||||
"audio:userStartAudio",
|
||||
"audio:userStopAudio",
|
||||
"debug:isEnabled"
|
||||
]
|
||||
|
||||
@ -100,7 +100,9 @@
|
||||
"startBacking",
|
||||
"stopAudio",
|
||||
"stopBacking",
|
||||
"unbindInputDevice"
|
||||
"unbindInputDevice",
|
||||
"userStartAudio",
|
||||
"userStopAudio"
|
||||
],
|
||||
"audioEffects": [
|
||||
"activateSegment",
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user