fix(audio): close the PR #107 review findings

Seven fixes on top of the audio-engine TLC branch, each with the gate that
catches its regression.

Blocking:

- Monitor-mute suppression leaked its refcount. setMonitorMuteSuppressed()
  became a refcounted acquire/release, but screen.js's callers are
  deliberately unpaired: resolveChainRebuildGuard() leaves the suppression on
  when a rebuild yields an empty chain, and returns early without releasing
  while a provider route is still resolving. Harmless against the old latched
  bool, a permanent +1 each against a refcount — after a failed tone rebuild
  the count never returned to zero and monitor mute was silently dead for the
  rest of the session. The renderer now holds at most one suppression.

- Slot ids are monotonic HANDLES (nextSlotId, never reset by clear()), not
  bounded indices, so argSlotId's 4096 ceiling meant that once a session
  created its 4096th processor EVERY guarded binding — setBypass,
  setParameter, remove/moveProcessor, open/closePluginEditor — silently
  no-opped for the rest of the run. Ceiling removed (same for
  SetMultiBypass's hardcoded 4096); unknown ids are still rejected by
  SignalChain::findSlotIndex.

- clearChain / removeProcessor / moveProcessor took chainMutationMutex with a
  blocking lock_guard on the N-API thread — Electron's main thread, and on
  macOS also the JUCE message thread. LoadPreset/LoadVST hold that mutex
  across an unbounded plugin init (done->wait() has no timeout by design), so
  a slow plugin froze the whole main process, every IPC channel with it. They
  now queue on a libuv worker via queueChainMutation() and resolve a promise;
  the bridge awaits them so callers still observe the mutation applied.

Also:

- getChainState() dereferenced raw ProcessorSlot* returned by getAllSlots()
  after the lock was dropped — a concurrent clear() frees them under the
  reader. Replaced with SignalChain::getSlotSummaries(), which copies under
  the lock. getAllSlots() is gone (it had one caller).
- The device-settings migration removed the localStorage copy even when the
  file-store save failed or was unavailable, losing the user's settings.
- SetSlotState and GetParameters kept the raw Int32Value() path: IsNumber()
  is true for NaN, so setSlotState(NaN) wrote onto slot 0 — the same
  coercion class the rest of the branch fixed.
- RendererBus flushed to the LIVE writeIndex, so a disable→re-enable with no
  pull in between discarded the freshly pushed audio along with the stale
  tail. It now snapshots the flush target at disable time.
- LoadPreset's rebuild barrier is now released by a scope guard, so a throw
  between arming it and Queue() can't block editor opens forever.

Gates: new renderer-bus case (fails on the old flush), new slot-id-handle
case (fails on the old ceiling). ctest 9/9, npm test 79 pass / 0 fail,
chain-mutation storm green, addon export contract unchanged.
This commit is contained in:
byrongamatos
2026-07-14 14:29:03 +02:00
parent 1ba9b59e8a
commit a332c35c9b
11 changed files with 362 additions and 80 deletions
+30
View File
@@ -166,6 +166,35 @@ static void testFlushOnDisable()
assert(dl[1] == 7.0f && "post-re-enable audio must be the fresh push");
}
// A pending flush must drop the STALE tail only. If no output callback runs
// between the disable and a re-enable (stopped device, device swap), the
// flush is still pending when fresh audio arrives — flushing to the live
// writeIndex at that point would discard the re-enabled bus's first frames
// too, silencing it until it re-primed. The flush target is snapshotted at
// disable time instead.
static void testFlushSparesPostReEnableAudio()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const auto stale = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f);
bus.push(stale.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0);
// Disable + re-enable with NO pull in between: the flush is still pending.
bus.setEnabled(false, 1.0f);
bus.setEnabled(true, 1.0f);
// Fresh audio pushed while the flush is still pending must survive it.
const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f);
bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0);
std::vector<float> dl(64), dr(64);
assert(bus.pull(dl.data(), dr.data(), 64) == 64
&& "fresh post-re-enable audio must not be flushed away with the stale tail");
// Frame 0 is the resampler's one-frame interpolation carry (by design);
// everything after must be the fresh push, never the flushed 5.0 tail.
assert(dl[1] == 7.0f && "flush must drop only the pre-disable tail");
}
// Rate validation (PR #107 review): non-finite rates cross the JS/IPC
// boundary; NaN passes a plain `<= 0` check, and a subnormal source rate can
// underflow step to 0 — both must be rejected before the resample loop.
@@ -202,6 +231,7 @@ int main()
testDisabledIsInert();
testGainApplied();
testFlushOnDisable();
testFlushSparesPostReEnableAudio();
std::puts("renderer_bus: all cases passed");
return 0;
}