diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index 8df727a..1ce3f3f 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -257,8 +257,11 @@ function loadNativeAddon(): AudioModule | null { export function initAudioBridge(): void { audio = loadNativeAddon(); - const audioEffects = createAudioEffectsExecutor(() => audio); leaseBridge = initLeaseBridge(() => audio); + // The executor sees a gated native surface: its `startAudio: true` chain + // plans (rig_builder song loads) must not undo a user stop (§8.3) — that + // path calls native directly and never crosses the IPC latch check. + const audioEffects = createAudioEffectsExecutor(leaseBridge.gateNativeAudio(() => audio)); // ── Lease registry surface (ownership plan §2/§8; wiring in lease-bridge) ── diff --git a/src/main/lease-bridge.ts b/src/main/lease-bridge.ts index d847d04..13fe5eb 100644 --- a/src/main/lease-bridge.ts +++ b/src/main/lease-bridge.ts @@ -47,6 +47,10 @@ export type LeaseBridge = { // keep-alive watchdog, any unmigrated caller) must be suppressed — only // an explicit user start clears it. isUserStopLatched(): boolean; + // Wrap a native-audio accessor so its startAudio honors the user-stop + // latch — for main-process callers that bypass the IPC handlers (the + // audio-effects executor's `startAudio: true` chain plans, plan 6.1). + gateNativeAudio any>>(getAudio: () => T | null): () => T | null; // 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; @@ -249,6 +253,30 @@ export function initLeaseBridge(getAudio: () => AudioModule, options: { broadcas return userStopLatch; }, + gateNativeAudio(getAudioFn) { + return () => { + const target = getAudioFn(); + if (!target) return null; + return new Proxy(target, { + get(t, prop, receiver) { + if (prop === 'startAudio') { + return (...args: unknown[]) => { + if (userStopLatch) { + // Strict §8.3: only the device screen's + // user start resumes — a chain plan's + // startAudio waits it out. + console.info('[leases] executor startAudio suppressed — user stop latched (plan §8.3)'); + return undefined; + } + return t.startAudio?.(...args); + }; + } + return Reflect.get(t, prop, receiver); + }, + }); + }; + }, + shouldIgnoreRawDetectionDisarm() { return anyDetectionActive(); }, diff --git a/tests/lease-bridge.test.js b/tests/lease-bridge.test.js index e5d0252..74429d0 100644 --- a/tests/lease-bridge.test.js +++ b/tests/lease-bridge.test.js @@ -127,6 +127,31 @@ test('user-stop latch: fresh demands and legacy starts held off until user start bridge.dispose(); }); +test('gateNativeAudio: executor startAudio suppressed under the latch, transparent otherwise', () => { + const audio = fakeAudio(); + const { bridge } = makeBridge(audio); + const gated = bridge.gateNativeAudio(() => audio)(); + + // Transparent pass-through when not latched — other methods untouched. + gated.startAudio(); + assert.deepEqual(audio.calls, ['start']); + assert.equal(gated.isAudioRunning(), true); + + audio.stopAudio(); + audio.calls.length = 0; + bridge.onUserStopAudio(); + + // Chain plan's startAudio:true waits out the user stop (strict §8.3). + gated.startAudio(); + assert.deepEqual(audio.calls, []); + + bridge.onUserStartAudio(); // user start clears; engine started by it + audio.calls.length = 0; + gated.startAudio(); + assert.deepEqual(audio.calls, ['start']); + bridge.dispose(); +}); + test('detection demand arms native; raw disarm guarded while demand active (6.3)', () => { const audio = fakeAudio(); const { bridge } = makeBridge(audio);