From 88f881dd1e5ba69325fe95e5d78ac6c9d2b42d11 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 03:01:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(audio):=20refcounted=20monitor-mute=20arbit?= =?UTF-8?q?er=20(TLC=20Part=20II=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old single monitorMuted atomic had five writers fighting last-writer-wins: the settings checkbox, startup restore, the executor's preload read-force-restore, releaseRoute's unconditional setMonitorMute(true) (which clobbered the user's persisted preference), and the renderer's song-load suppression (un-refcounted — overlapping windows un-suppressed each other early). Native arbiter on SourceChain: userMonitorMute (the preference — checkbox + restore only), refcounted monitorMuteHolds (force-mute overrides), and refcounted suppressions (setMonitorMuteSuppressed keeps its bool surface; true=acquire, false=release, clamped at 0). Effective dry-mute = (holds || pref) && chain empty && no suppression — the suppressed-beats-muted precedence is unchanged. New exports: acquire/releaseMonitorMuteHold, getMonitorMuteState (diag); snapshots regenerated. Executor rewrite: acquires a suppression (dry-during-load, the default) or a hold, and releases exactly what it acquired via a single-fire closure that runs UNCONDITIONALLY (each load owns its acquisition — the stale-snapshot race against a mid-hold user toggle is structurally gone). releaseRoute no longer touches mute state at all. The ownership test now pins: preference API never called, acquire/release balanced. Renderer callers are unchanged: the checkbox writes the preference as before, and the song-load suppression sites now compose instead of racing. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.h | 5 ++ src/audio/NodeAddon.cpp | 6 +++ src/audio/SourceChain.cpp | 4 +- src/audio/SourceChain.h | 43 ++++++++++++--- src/audio/addon/Bindings.h | 3 ++ src/audio/addon/ControlBindings.cpp | 31 +++++++++++ src/main/audio-effects-executor.ts | 80 +++++++++++++++++----------- tests/audio-effects-executor.test.js | 18 +++++-- tests/contracts/addon-exports.json | 3 ++ 9 files changed, 151 insertions(+), 42 deletions(-) diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index c578afc..c0999bf 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -165,6 +165,11 @@ public: // so the brief empty-chain window doesn't silence the player's guitar. void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); } bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); } + // Refcounted force-mute overrides (see SourceChain's arbiter comment). + void acquireMonitorMuteHold() { source0().acquireMonitorMuteHold(); } + void releaseMonitorMuteHold() { source0().releaseMonitorMuteHold(); } + int getMonitorMuteHoldCount() const { return source0().getMonitorMuteHoldCount(); } + int getMonitorMuteSuppressCount() const { return source0().getMonitorMuteSuppressCount(); } // Full monitor kill — silences the guitar bus entirely (dry + processed), // for monitoring through an external rig. Unlike the per-source mute/gain diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 4fe7664..f05ce03 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -106,6 +106,9 @@ using slopsmith::addon::SetInputChannel; using slopsmith::addon::SetMonitorKill; using slopsmith::addon::SetMonitorMute; using slopsmith::addon::SetMonitorMuteSuppressed; +using slopsmith::addon::AcquireMonitorMuteHold; +using slopsmith::addon::ReleaseMonitorMuteHold; +using slopsmith::addon::GetMonitorMuteState; using slopsmith::addon::SetMultiBypass; using slopsmith::addon::SetNoiseGate; using slopsmith::addon::SetNoteDetectionEnabled; @@ -346,6 +349,9 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports) exports.Set("setInputChannel", Napi::Function::New(env, SetInputChannel)); exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute)); exports.Set("setMonitorMuteSuppressed", Napi::Function::New(env, SetMonitorMuteSuppressed)); + exports.Set("acquireMonitorMuteHold", Napi::Function::New(env, AcquireMonitorMuteHold)); + exports.Set("releaseMonitorMuteHold", Napi::Function::New(env, ReleaseMonitorMuteHold)); + exports.Set("getMonitorMuteState", Napi::Function::New(env, GetMonitorMuteState)); exports.Set("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted)); exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill)); exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate)); diff --git a/src/audio/SourceChain.cpp b/src/audio/SourceChain.cpp index 93787ba..7ae7142 100644 --- a/src/audio/SourceChain.cpp +++ b/src/audio/SourceChain.cpp @@ -252,7 +252,9 @@ void SourceChain::processBlock(const float* const* inputData, int numInputChanne // chain yet. Backing track still plays through. Suppressed during a song-load // chain rebuild so the brief (or failed) empty-chain window doesn't silence // the guitar. - if (monitorMuted.load() && !hasProcessors && !monitorMuteSuppressed.load()) + if ((monitorMuteHolds.load(std::memory_order_acquire) > 0 || userMonitorMute.load()) + && !hasProcessors + && monitorMuteSuppress.load(std::memory_order_acquire) == 0) buffer.clear(); // Full monitor kill: silence the guitar bus unconditionally — dry AND the diff --git a/src/audio/SourceChain.h b/src/audio/SourceChain.h index 5f8fee6..7f44d5b 100644 --- a/src/audio/SourceChain.h +++ b/src/audio/SourceChain.h @@ -142,10 +142,40 @@ public: // then a channel index WITHIN the bound device. void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); } int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); } - void setMonitorMute(bool mute) { monitorMuted.store(mute); } - bool isMonitorMuted() const { return monitorMuted.load(); } - void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); } - bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); } + // ── Monitor-mute arbiter (TLC Part II §2 fix) ───────────────────────────── + // The old single monitorMuted atomic had FIVE writers (settings checkbox, + // startup restore, executor preload-mute, executor releaseRoute, renderer + // song-load suppression) fighting last-writer-wins — releaseRoute clobbered + // the user's persisted preference and overlapping suppression windows + // un-suppressed each other. Now three composable inputs: + // userMonitorMute — the PREFERENCE (checkbox + startup restore). + // monitorMuteHolds — refcounted "force mute" overrides (executor + // preload-mute); released, never "restored". + // monitorMuteSuppress — refcounted "force unmute" windows (song-load + // chain rebuilds). Wins over pref + holds, + // preserving the old suppressed-beats-muted rule. + // effective dry-mute = (holds>0 || pref) && chain empty && suppress==0. + void setMonitorMute(bool mute) { userMonitorMute.store(mute); } + bool isMonitorMuted() const { return userMonitorMute.load(); } + void acquireMonitorMuteHold() { monitorMuteHolds.fetch_add(1, std::memory_order_acq_rel); } + void releaseMonitorMuteHold() + { + // Clamp at 0: an unpaired release (old callers, crashed holder) must + // not underflow into a permanently-forced state. + int cur = monitorMuteHolds.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteHolds.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + // Back-compat surface: true = acquire a suppression, false = release one. + // Overlapping windows now compose instead of last-clear-wins. + void setMonitorMuteSuppressed(bool s) + { + if (s) { monitorMuteSuppress.fetch_add(1, std::memory_order_acq_rel); return; } + int cur = monitorMuteSuppress.load(std::memory_order_acquire); + while (cur > 0 && !monitorMuteSuppress.compare_exchange_weak(cur, cur - 1, std::memory_order_acq_rel)) {} + } + bool isMonitorMuteSuppressed() const { return monitorMuteSuppress.load(std::memory_order_acquire) > 0; } + int getMonitorMuteHoldCount() const { return monitorMuteHolds.load(std::memory_order_acquire); } + int getMonitorMuteSuppressCount() const { return monitorMuteSuppress.load(std::memory_order_acquire); } // Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the // processed/amp-sim signal), unlike setMonitorMute which only mutes the dry // pass-through when no processors are loaded. For users who monitor through @@ -200,8 +230,9 @@ private: std::atomic deviceKey{0}; // 0 = primary input device std::atomic verifierAutoOffset{0.0}; // engine: device-latency delta std::atomic verifierUserOffset{0.0}; // renderer: manual fine-tune - std::atomic monitorMuted{true}; - std::atomic monitorMuteSuppressed{false}; + std::atomic userMonitorMute{true}; + std::atomic monitorMuteHolds{0}; + std::atomic monitorMuteSuppress{0}; std::atomic monitorKill{false}; std::atomic nonFiniteChainBlocks{0}; diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h index acc79f4..641ca4c 100644 --- a/src/audio/addon/Bindings.h +++ b/src/audio/addon/Bindings.h @@ -77,6 +77,9 @@ Napi::Value SetDevice(const Napi::CallbackInfo& info); Napi::Value SetDeviceType(const Napi::CallbackInfo& info); Napi::Value SetGain(const Napi::CallbackInfo& info); Napi::Value SetInputChannel(const Napi::CallbackInfo& info); +Napi::Value AcquireMonitorMuteHold(const Napi::CallbackInfo& info); +Napi::Value ReleaseMonitorMuteHold(const Napi::CallbackInfo& info); +Napi::Value GetMonitorMuteState(const Napi::CallbackInfo& info); Napi::Value SetMonitorKill(const Napi::CallbackInfo& info); Napi::Value SetMonitorMute(const Napi::CallbackInfo& info); Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info); diff --git a/src/audio/addon/ControlBindings.cpp b/src/audio/addon/ControlBindings.cpp index fde10a3..470fa94 100644 --- a/src/audio/addon/ControlBindings.cpp +++ b/src/audio/addon/ControlBindings.cpp @@ -80,6 +80,37 @@ Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info) return info.Env().Undefined(); } +// Refcounted force-mute overrides (monitor-mute arbiter, TLC Part II §2). +// The audio-effects executor holds one across a chain load and RELEASES it +// afterwards — it never reads/writes the user's mute preference anymore. +Napi::Value AcquireMonitorMuteHold(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) + liveEngine->acquireMonitorMuteHold(); + return info.Env().Undefined(); +} + +Napi::Value ReleaseMonitorMuteHold(const Napi::CallbackInfo& info) +{ + if (auto liveEngine = snapshotEngine()) + liveEngine->releaseMonitorMuteHold(); + return info.Env().Undefined(); +} + +// Diagnostic/testing view of the arbiter's three inputs. +Napi::Value GetMonitorMuteState(const Napi::CallbackInfo& info) +{ + auto env = info.Env(); + auto obj = Napi::Object::New(env); + if (auto liveEngine = snapshotEngine()) + { + obj.Set("userMute", liveEngine->isMonitorMuted()); + obj.Set("holds", liveEngine->getMonitorMuteHoldCount()); + obj.Set("suppressions", liveEngine->getMonitorMuteSuppressCount()); + } + return obj; +} + Napi::Value SetMonitorKill(const Napi::CallbackInfo& info) { // IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller), diff --git a/src/main/audio-effects-executor.ts b/src/main/audio-effects-executor.ts index ac02a89..291b402 100644 --- a/src/main/audio-effects-executor.ts +++ b/src/main/audio-effects-executor.ts @@ -36,6 +36,8 @@ type AudioEffectsNativeAudio = { setGain?: (which: string, value: number) => Promise | unknown; setMonitorMute?: (muted: boolean) => Promise | unknown; setMonitorMuteSuppressed?: (suppressed: boolean) => Promise | unknown; + acquireMonitorMuteHold?: () => Promise | unknown; + releaseMonitorMuteHold?: () => Promise | unknown; isMonitorMuted?: () => Promise | unknown; startAudio?: () => Promise | unknown; }; @@ -413,23 +415,35 @@ async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: u } } -async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise { - if (typeof nativeAudio.isMonitorMuted !== 'function') return null; - try { - return Boolean(await nativeAudio.isMonitorMuted()); - } catch (_) { - return null; +// Monitor-mute arbiter (TLC Part II §2): the executor no longer reads or +// writes the user's mute PREFERENCE. During a load it acquires a refcounted +// override on the native arbiter — a force-mute hold (default) or a +// suppression (dryDuringLoad: dry guitar stays audible) — and RELEASES it +// afterwards. Returns a single-fire release closure (safe to call from a +// timer even after newer loads: each load owns its own acquisition, so +// releasing can never clobber another writer's state, which is exactly the +// stale-snapshot race the old read-modify-restore had). +async function acquireMuteOverride(nativeAudio: AudioEffectsNativeAudio, dryDuringLoad: boolean): Promise<() => Promise> { + let released = false; + if (dryDuringLoad && typeof nativeAudio.setMonitorMuteSuppressed === 'function') { + try { await nativeAudio.setMonitorMuteSuppressed(true); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.setMonitorMuteSuppressed!(false); } catch (_) { /* best effort */ } + }; } -} - -async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise { - if (typeof nativeAudio.setMonitorMute !== 'function') return; - try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ } -} - -async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise { - if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return; - try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ } + if (!dryDuringLoad && typeof nativeAudio.acquireMonitorMuteHold === 'function') { + try { await nativeAudio.acquireMonitorMuteHold(); } catch (_) { return async () => { /* never acquired */ }; } + return async () => { + if (released) return; + released = true; + try { await nativeAudio.releaseMonitorMuteHold?.(); } catch (_) { /* best effort */ } + }; + } + // Addon predates the arbiter — degrade to no mute forcing rather than + // reintroducing the preference-clobbering read/force/restore. + return async () => { /* nothing acquired */ }; } async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise { @@ -449,10 +463,13 @@ async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGain return failed; } -function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, previousMonitorMute: boolean | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { +function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, releaseMuteOverride: (() => Promise) | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void { const restore = async () => { + // The override release is UNCONDITIONAL: this load acquired it, this + // load must release it, even when a newer load superseded the gain + // ramp (refcounts compose — the newer load holds its own). + if (releaseMuteOverride) await releaseMuteOverride(); if (shouldRestore && !shouldRestore()) return; - if (previousMonitorMute !== null) await trySetMonitorMute(nativeAudio, previousMonitorMute); const restoreTarget = clampGain(targetGain, 1); const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget]; for (const value of steps) { @@ -489,25 +506,24 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { const started = Date.now(); const restoreVersion = ++preloadRestoreVersion; const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null; - let previousMonitorMute: boolean | null = null; + let releaseMuteOverride: (() => Promise) | null = null; if (options.preloadMute?.enabled) { - previousMonitorMute = await readMonitorMuted(nativeAudio); await trySetGain(nativeAudio, 'chain', 0); - await trySetMonitorMute(nativeAudio, options.preloadMute.dryDuringLoad ? false : true); + releaseMuteOverride = await acquireMuteOverride(nativeAudio, options.preloadMute.dryDuringLoad === true); } let result: { success: boolean; slotsLoaded: number; error: string; chainGeneration: number }; try { result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson)); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load threw', { error: bounded(error instanceof Error ? error.message : String(error)), rollbackApplied }); } const nativeStages = validation.plan.stages.filter((stage) => stage.native); if (!result.success) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native audio-effects plan load failed', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -522,7 +538,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { if (result.slotsLoaded < nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -539,7 +555,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { slots = chainSlots(nativeAudio); } catch (error) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('failed', 'Native chain-state lookup threw', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -559,7 +575,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { // as handled while later stage operations silently return no-target. Roll back instead. if (stageSlots.size !== nativeStages.length) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -576,7 +592,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { const generationNow = currentChainGeneration(nativeAudio); if (result.chainGeneration >= 0 && generationNow >= 0 && generationNow !== result.chainGeneration) { const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset); - if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); + if (options.preloadMute?.enabled) schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, 0, () => restoreVersion === preloadRestoreVersion); return safeOutcome('degraded', 'Native chain was modified by another writer during plan load', { routeKey: validation.plan.routeKey, providerId: validation.plan.providerId, @@ -607,7 +623,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ } } if (options.preloadMute?.enabled) { - schedulePreloadRestore(nativeAudio, previousMonitorMute, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { + schedulePreloadRestore(nativeAudio, releaseMuteOverride, options.gains.chain ?? options.preloadMute.targetGain, options.preloadMute.holdMs, () => { const current = routes.get(validation.plan.routeKey); return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId; }); @@ -645,8 +661,12 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) { } catch (error) { releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) }); } - await trySetMonitorMute(nativeAudio, true); - await trySetMonitorMuteSuppressed(nativeAudio, false); + // Arbiter fix: releaseRoute used to FORCE monitorMute=true and clear + // suppression unconditionally — clobbering the user's persisted + // preference and any other writer's suppression window. The chain is + // cleared above, so the engine's own empty-chain dry-mute semantics + // apply; any preload override this executor still holds is released + // by its own scheduled closure. if (releaseFailure) return updateOutcome(route, releaseFailure); routes.delete(routeKey); return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures }); diff --git a/tests/audio-effects-executor.test.js b/tests/audio-effects-executor.test.js index e072a06..8104c15 100644 --- a/tests/audio-effects-executor.test.js +++ b/tests/audio-effects-executor.test.js @@ -200,18 +200,26 @@ test('audio-effects executor owns load mute, route gain, start, and release', as assert.equal(gained.outcome, 'handled'); assert.equal(released.outcome, 'handled'); assert.equal(inspected.outcome, 'no-target'); - assert.deepEqual(calls.slice(0, 7), [ - ['is-muted'], + // Monitor-mute arbiter (TLC Part II §2): the executor never reads or + // writes the user's mute preference — it acquires a suppression for the + // dry-during-load window (default) and releases exactly what it acquired. + assert.deepEqual(calls.slice(0, 6), [ ['gain', 'chain', 0], - ['monitor', false], + ['suppress', true], ['load', 2], ['gain', 'input', 8], ['start'], ['gain', 'chain', 2], ]); assert.equal(calls.some(call => call[0] === 'clear'), true); - assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true); - assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true); + // The preference API is untouched, in both directions — releaseRoute no + // longer forces monitorMute=true over the user's persisted choice. + assert.equal(calls.some(call => call[0] === 'is-muted'), false); + assert.equal(calls.some(call => call[0] === 'monitor'), false); + // The suppression is balanced: one acquire, one release — never an + // unpaired clear that would cancel another writer's window. + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === true).length, 1); + assert.equal(calls.filter(call => call[0] === 'suppress' && call[1] === false).length, 1); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 4), false); assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true); }); diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json index aac4fa5..dd85205 100644 --- a/tests/contracts/addon-exports.json +++ b/tests/contracts/addon-exports.json @@ -1,4 +1,5 @@ [ + "acquireMonitorMuteHold", "addSource", "bindInputDevice", "clearChain", @@ -17,6 +18,7 @@ "getDeviceTypes", "getKnownPlugins", "getLevels", + "getMonitorMuteState", "getNoteVerdicts", "getParameters", "getPitchDetection", @@ -52,6 +54,7 @@ "openPluginEditor", "probeDeviceOptions", "pushRendererAudio", + "releaseMonitorMuteHold", "removeProcessor", "removeSource", "replaceIR",