mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-12 17:48:12 +00:00
fix(audio): refcounted monitor-mute arbiter (TLC Part II §2)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2906d2814b
commit
88f881dd1e
@@ -165,6 +165,11 @@ public:
|
|||||||
// so the brief empty-chain window doesn't silence the player's guitar.
|
// so the brief empty-chain window doesn't silence the player's guitar.
|
||||||
void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); }
|
void setMonitorMuteSuppressed(bool suppressed) { source0().setMonitorMuteSuppressed(suppressed); }
|
||||||
bool isMonitorMuteSuppressed() const { return source0().isMonitorMuteSuppressed(); }
|
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),
|
// Full monitor kill — silences the guitar bus entirely (dry + processed),
|
||||||
// for monitoring through an external rig. Unlike the per-source mute/gain
|
// for monitoring through an external rig. Unlike the per-source mute/gain
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ using slopsmith::addon::SetInputChannel;
|
|||||||
using slopsmith::addon::SetMonitorKill;
|
using slopsmith::addon::SetMonitorKill;
|
||||||
using slopsmith::addon::SetMonitorMute;
|
using slopsmith::addon::SetMonitorMute;
|
||||||
using slopsmith::addon::SetMonitorMuteSuppressed;
|
using slopsmith::addon::SetMonitorMuteSuppressed;
|
||||||
|
using slopsmith::addon::AcquireMonitorMuteHold;
|
||||||
|
using slopsmith::addon::ReleaseMonitorMuteHold;
|
||||||
|
using slopsmith::addon::GetMonitorMuteState;
|
||||||
using slopsmith::addon::SetMultiBypass;
|
using slopsmith::addon::SetMultiBypass;
|
||||||
using slopsmith::addon::SetNoiseGate;
|
using slopsmith::addon::SetNoiseGate;
|
||||||
using slopsmith::addon::SetNoteDetectionEnabled;
|
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("setInputChannel", Napi::Function::New(env, SetInputChannel));
|
||||||
exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute));
|
exports.Set("setMonitorMute", Napi::Function::New(env, SetMonitorMute));
|
||||||
exports.Set("setMonitorMuteSuppressed", Napi::Function::New(env, SetMonitorMuteSuppressed));
|
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("isMonitorMuted", Napi::Function::New(env, IsMonitorMuted));
|
||||||
exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill));
|
exports.Set("setMonitorKill", Napi::Function::New(env, SetMonitorKill));
|
||||||
exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate));
|
exports.Set("setNoiseGate", Napi::Function::New(env, SetNoiseGate));
|
||||||
|
|||||||
@@ -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 yet. Backing track still plays through. Suppressed during a song-load
|
||||||
// chain rebuild so the brief (or failed) empty-chain window doesn't silence
|
// chain rebuild so the brief (or failed) empty-chain window doesn't silence
|
||||||
// the guitar.
|
// 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();
|
buffer.clear();
|
||||||
|
|
||||||
// Full monitor kill: silence the guitar bus unconditionally — dry AND the
|
// Full monitor kill: silence the guitar bus unconditionally — dry AND the
|
||||||
|
|||||||
+37
-6
@@ -142,10 +142,40 @@ public:
|
|||||||
// then a channel index WITHIN the bound device.
|
// then a channel index WITHIN the bound device.
|
||||||
void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); }
|
void setDeviceKey(int key) { deviceKey.store(key, std::memory_order_release); }
|
||||||
int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); }
|
int getDeviceKey() const { return deviceKey.load(std::memory_order_acquire); }
|
||||||
void setMonitorMute(bool mute) { monitorMuted.store(mute); }
|
// ── Monitor-mute arbiter (TLC Part II §2 fix) ─────────────────────────────
|
||||||
bool isMonitorMuted() const { return monitorMuted.load(); }
|
// The old single monitorMuted atomic had FIVE writers (settings checkbox,
|
||||||
void setMonitorMuteSuppressed(bool s) { monitorMuteSuppressed.store(s); }
|
// startup restore, executor preload-mute, executor releaseRoute, renderer
|
||||||
bool isMonitorMuteSuppressed() const { return monitorMuteSuppressed.load(); }
|
// 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
|
// Full monitor kill — silences the guitar bus UNCONDITIONALLY (dry AND the
|
||||||
// processed/amp-sim signal), unlike setMonitorMute which only mutes the dry
|
// processed/amp-sim signal), unlike setMonitorMute which only mutes the dry
|
||||||
// pass-through when no processors are loaded. For users who monitor through
|
// pass-through when no processors are loaded. For users who monitor through
|
||||||
@@ -200,8 +230,9 @@ private:
|
|||||||
std::atomic<int> deviceKey{0}; // 0 = primary input device
|
std::atomic<int> deviceKey{0}; // 0 = primary input device
|
||||||
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
|
std::atomic<double> verifierAutoOffset{0.0}; // engine: device-latency delta
|
||||||
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
|
std::atomic<double> verifierUserOffset{0.0}; // renderer: manual fine-tune
|
||||||
std::atomic<bool> monitorMuted{true};
|
std::atomic<bool> userMonitorMute{true};
|
||||||
std::atomic<bool> monitorMuteSuppressed{false};
|
std::atomic<int> monitorMuteHolds{0};
|
||||||
|
std::atomic<int> monitorMuteSuppress{0};
|
||||||
std::atomic<bool> monitorKill{false};
|
std::atomic<bool> monitorKill{false};
|
||||||
std::atomic<uint32_t> nonFiniteChainBlocks{0};
|
std::atomic<uint32_t> nonFiniteChainBlocks{0};
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ Napi::Value SetDevice(const Napi::CallbackInfo& info);
|
|||||||
Napi::Value SetDeviceType(const Napi::CallbackInfo& info);
|
Napi::Value SetDeviceType(const Napi::CallbackInfo& info);
|
||||||
Napi::Value SetGain(const Napi::CallbackInfo& info);
|
Napi::Value SetGain(const Napi::CallbackInfo& info);
|
||||||
Napi::Value SetInputChannel(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 SetMonitorKill(const Napi::CallbackInfo& info);
|
||||||
Napi::Value SetMonitorMute(const Napi::CallbackInfo& info);
|
Napi::Value SetMonitorMute(const Napi::CallbackInfo& info);
|
||||||
Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info);
|
Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info);
|
||||||
|
|||||||
@@ -80,6 +80,37 @@ Napi::Value SetMonitorMuteSuppressed(const Napi::CallbackInfo& info)
|
|||||||
return info.Env().Undefined();
|
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)
|
Napi::Value SetMonitorKill(const Napi::CallbackInfo& info)
|
||||||
{
|
{
|
||||||
// IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller),
|
// IsBoolean()-guarded (fail-soft no-op on a downlevel/mismatched caller),
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type AudioEffectsNativeAudio = {
|
|||||||
setGain?: (which: string, value: number) => Promise<unknown> | unknown;
|
setGain?: (which: string, value: number) => Promise<unknown> | unknown;
|
||||||
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
|
setMonitorMute?: (muted: boolean) => Promise<unknown> | unknown;
|
||||||
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
|
setMonitorMuteSuppressed?: (suppressed: boolean) => Promise<unknown> | unknown;
|
||||||
|
acquireMonitorMuteHold?: () => Promise<unknown> | unknown;
|
||||||
|
releaseMonitorMuteHold?: () => Promise<unknown> | unknown;
|
||||||
isMonitorMuted?: () => Promise<unknown> | unknown;
|
isMonitorMuted?: () => Promise<unknown> | unknown;
|
||||||
startAudio?: () => Promise<unknown> | unknown;
|
startAudio?: () => Promise<unknown> | unknown;
|
||||||
};
|
};
|
||||||
@@ -413,23 +415,35 @@ async function restorePreset(nativeAudio: AudioEffectsNativeAudio, presetJson: u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMonitorMuted(nativeAudio: AudioEffectsNativeAudio): Promise<boolean | null> {
|
// Monitor-mute arbiter (TLC Part II §2): the executor no longer reads or
|
||||||
if (typeof nativeAudio.isMonitorMuted !== 'function') return null;
|
// writes the user's mute PREFERENCE. During a load it acquires a refcounted
|
||||||
try {
|
// override on the native arbiter — a force-mute hold (default) or a
|
||||||
return Boolean(await nativeAudio.isMonitorMuted());
|
// suppression (dryDuringLoad: dry guitar stays audible) — and RELEASES it
|
||||||
} catch (_) {
|
// afterwards. Returns a single-fire release closure (safe to call from a
|
||||||
return null;
|
// 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<void>> {
|
||||||
|
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 */ }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
if (!dryDuringLoad && typeof nativeAudio.acquireMonitorMuteHold === 'function') {
|
||||||
|
try { await nativeAudio.acquireMonitorMuteHold(); } catch (_) { return async () => { /* never acquired */ }; }
|
||||||
async function trySetMonitorMute(nativeAudio: AudioEffectsNativeAudio, muted: boolean): Promise<void> {
|
return async () => {
|
||||||
if (typeof nativeAudio.setMonitorMute !== 'function') return;
|
if (released) return;
|
||||||
try { await nativeAudio.setMonitorMute(muted); } catch (_) { /* best effort */ }
|
released = true;
|
||||||
}
|
try { await nativeAudio.releaseMonitorMuteHold?.(); } catch (_) { /* best effort */ }
|
||||||
|
};
|
||||||
async function trySetMonitorMuteSuppressed(nativeAudio: AudioEffectsNativeAudio, suppressed: boolean): Promise<void> {
|
}
|
||||||
if (typeof nativeAudio.setMonitorMuteSuppressed !== 'function') return;
|
// Addon predates the arbiter — degrade to no mute forcing rather than
|
||||||
try { await nativeAudio.setMonitorMuteSuppressed(suppressed); } catch (_) { /* best effort */ }
|
// reintroducing the preference-clobbering read/force/restore.
|
||||||
|
return async () => { /* nothing acquired */ };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise<boolean> {
|
async function trySetGain(nativeAudio: AudioEffectsNativeAudio, which: string, value: number): Promise<boolean> {
|
||||||
@@ -449,10 +463,13 @@ async function applyGains(nativeAudio: AudioEffectsNativeAudio, gains: RouteGain
|
|||||||
return failed;
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, previousMonitorMute: boolean | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void {
|
function schedulePreloadRestore(nativeAudio: AudioEffectsNativeAudio, releaseMuteOverride: (() => Promise<void>) | null, targetGain: number, holdMs: number, shouldRestore?: () => boolean): void {
|
||||||
const restore = async () => {
|
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 (shouldRestore && !shouldRestore()) return;
|
||||||
if (previousMonitorMute !== null) await trySetMonitorMute(nativeAudio, previousMonitorMute);
|
|
||||||
const restoreTarget = clampGain(targetGain, 1);
|
const restoreTarget = clampGain(targetGain, 1);
|
||||||
const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget];
|
const steps = [restoreTarget * 0.25, restoreTarget * 0.5, restoreTarget * 0.8, restoreTarget];
|
||||||
for (const value of steps) {
|
for (const value of steps) {
|
||||||
@@ -489,25 +506,24 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const restoreVersion = ++preloadRestoreVersion;
|
const restoreVersion = ++preloadRestoreVersion;
|
||||||
const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null;
|
const rollbackPreset = typeof nativeAudio.savePreset === 'function' ? nativeAudio.savePreset() : null;
|
||||||
let previousMonitorMute: boolean | null = null;
|
let releaseMuteOverride: (() => Promise<void>) | null = null;
|
||||||
if (options.preloadMute?.enabled) {
|
if (options.preloadMute?.enabled) {
|
||||||
previousMonitorMute = await readMonitorMuted(nativeAudio);
|
|
||||||
await trySetGain(nativeAudio, 'chain', 0);
|
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 };
|
let result: { success: boolean; slotsLoaded: number; error: string; chainGeneration: number };
|
||||||
try {
|
try {
|
||||||
result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson));
|
result = normalizeLoadResult(await nativeAudio.loadPreset(validation.presetJson));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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 });
|
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);
|
const nativeStages = validation.plan.stages.filter((stage) => stage.native);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('failed', 'Native audio-effects plan load failed', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -522,7 +538,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
|
|
||||||
if (result.slotsLoaded < nativeStages.length) {
|
if (result.slotsLoaded < nativeStages.length) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('degraded', 'Native audio-effects plan partially loaded and was rolled back', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -539,7 +555,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
slots = chainSlots(nativeAudio);
|
slots = chainSlots(nativeAudio);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('failed', 'Native chain-state lookup threw', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
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.
|
// as handled while later stage operations silently return no-target. Roll back instead.
|
||||||
if (stageSlots.size !== nativeStages.length) {
|
if (stageSlots.size !== nativeStages.length) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('degraded', 'Native slot mapping was incomplete and was rolled back', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -576,7 +592,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
const generationNow = currentChainGeneration(nativeAudio);
|
const generationNow = currentChainGeneration(nativeAudio);
|
||||||
if (result.chainGeneration >= 0 && generationNow >= 0 && generationNow !== result.chainGeneration) {
|
if (result.chainGeneration >= 0 && generationNow >= 0 && generationNow !== result.chainGeneration) {
|
||||||
const rollbackApplied = await restorePreset(nativeAudio, rollbackPreset);
|
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', {
|
return safeOutcome('degraded', 'Native chain was modified by another writer during plan load', {
|
||||||
routeKey: validation.plan.routeKey,
|
routeKey: validation.plan.routeKey,
|
||||||
providerId: validation.plan.providerId,
|
providerId: validation.plan.providerId,
|
||||||
@@ -607,7 +623,7 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ }
|
try { await nativeAudio.startAudio(); } catch (_) { /* load succeeded; start is best-effort */ }
|
||||||
}
|
}
|
||||||
if (options.preloadMute?.enabled) {
|
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);
|
const current = routes.get(validation.plan.routeKey);
|
||||||
return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId;
|
return restoreVersion === preloadRestoreVersion && current?.planId === validation.plan.planId;
|
||||||
});
|
});
|
||||||
@@ -645,8 +661,12 @@ export function createAudioEffectsExecutor(getAudio: NativeAudioGetter) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) });
|
releaseFailure = safeOutcome('failed', 'Native route release threw', { routeKey, error: bounded(error instanceof Error ? error.message : String(error)) });
|
||||||
}
|
}
|
||||||
await trySetMonitorMute(nativeAudio, true);
|
// Arbiter fix: releaseRoute used to FORCE monitorMute=true and clear
|
||||||
await trySetMonitorMuteSuppressed(nativeAudio, false);
|
// 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);
|
if (releaseFailure) return updateOutcome(route, releaseFailure);
|
||||||
routes.delete(routeKey);
|
routes.delete(routeKey);
|
||||||
return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures });
|
return safeOutcome('handled', 'Audio-effects route released', { routeKey, providerId: route.providerId, planId: route.planId, cleanupFailures });
|
||||||
|
|||||||
@@ -200,18 +200,26 @@ test('audio-effects executor owns load mute, route gain, start, and release', as
|
|||||||
assert.equal(gained.outcome, 'handled');
|
assert.equal(gained.outcome, 'handled');
|
||||||
assert.equal(released.outcome, 'handled');
|
assert.equal(released.outcome, 'handled');
|
||||||
assert.equal(inspected.outcome, 'no-target');
|
assert.equal(inspected.outcome, 'no-target');
|
||||||
assert.deepEqual(calls.slice(0, 7), [
|
// Monitor-mute arbiter (TLC Part II §2): the executor never reads or
|
||||||
['is-muted'],
|
// 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],
|
['gain', 'chain', 0],
|
||||||
['monitor', false],
|
['suppress', true],
|
||||||
['load', 2],
|
['load', 2],
|
||||||
['gain', 'input', 8],
|
['gain', 'input', 8],
|
||||||
['start'],
|
['start'],
|
||||||
['gain', 'chain', 2],
|
['gain', 'chain', 2],
|
||||||
]);
|
]);
|
||||||
assert.equal(calls.some(call => call[0] === 'clear'), true);
|
assert.equal(calls.some(call => call[0] === 'clear'), true);
|
||||||
assert.equal(calls.some(call => call[0] === 'monitor' && call[1] === true), true);
|
// The preference API is untouched, in both directions — releaseRoute no
|
||||||
assert.equal(calls.some(call => call[0] === 'suppress' && call[1] === false), true);
|
// 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] === 4), false);
|
||||||
assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true);
|
assert.equal(calls.some(call => call[0] === 'gain' && call[1] === 'chain' && call[2] === 0), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
"acquireMonitorMuteHold",
|
||||||
"addSource",
|
"addSource",
|
||||||
"bindInputDevice",
|
"bindInputDevice",
|
||||||
"clearChain",
|
"clearChain",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"getDeviceTypes",
|
"getDeviceTypes",
|
||||||
"getKnownPlugins",
|
"getKnownPlugins",
|
||||||
"getLevels",
|
"getLevels",
|
||||||
|
"getMonitorMuteState",
|
||||||
"getNoteVerdicts",
|
"getNoteVerdicts",
|
||||||
"getParameters",
|
"getParameters",
|
||||||
"getPitchDetection",
|
"getPitchDetection",
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"openPluginEditor",
|
"openPluginEditor",
|
||||||
"probeDeviceOptions",
|
"probeDeviceOptions",
|
||||||
"pushRendererAudio",
|
"pushRendererAudio",
|
||||||
|
"releaseMonitorMuteHold",
|
||||||
"removeProcessor",
|
"removeProcessor",
|
||||||
"removeSource",
|
"removeSource",
|
||||||
"replaceIR",
|
"replaceIR",
|
||||||
|
|||||||
Reference in New Issue
Block a user