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:
OmikronApex
2026-07-14 03:01:09 +02:00
co-authored by Claude Fable 5
parent 2906d2814b
commit 88f881dd1e
9 changed files with 151 additions and 42 deletions
+13 -5
View File
@@ -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);
});