feedBack-desktop/tests/engine_units/renderer_bus_test.cpp
byrongamatos a332c35c9b 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.
2026-07-14 14:29:03 +02:00

238 lines
9.9 KiB
C++

// Phase 2 unit tests for RendererBus (docs/audio-engine-tlc.md §5):
// resampler continuity across pushes, equal-rate bit-exactness, the prime
// gate, underflow → silence + re-prime, fill clamp, and metrics arithmetic.
// The flush-on-disable test flips once the phase-8 flush-flag fix lands.
#include "../../src/audio/engine/RendererBus.h"
#include <cassert>
#include <cmath>
#include <cstdio>
#include <limits>
#include <vector>
using slopsmith::RendererBus;
static std::vector<float> rampChunk(int frames, float start, float step)
{
std::vector<float> v((size_t) frames * 2);
for (int i = 0; i < frames; ++i)
{
v[(size_t) i * 2] = start + step * (float) i;
v[(size_t) i * 2 + 1] = -(start + step * (float) i);
}
return v;
}
// Equal rates degenerate to step == 1.0 — frames must come out bit-exact
// (minus the one-frame interpolation carry at each chunk boundary).
static void testEqualRateBitExact()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const auto c1 = rampChunk(512, 0.0f, 1.0f);
const auto c2 = rampChunk(512, 512.0f, 1.0f);
assert(bus.push(c1.data(), 512, 48000.0, 48000.0));
assert(bus.push(c2.data(), 512, 48000.0, 48000.0));
std::vector<float> dl(512), dr(512);
assert(bus.pull(dl.data(), dr.data(), 512) == 512);
for (int i = 0; i < 512; ++i)
{
// First chunk's frame 0 is consumed as interpolation carry (pos
// starts at 0 with prev=0 carry → exact frame i lands at output i).
assert(dl[(size_t) i] == (float) i && dr[(size_t) i] == -(float) i);
}
}
// Downsampling 2:1 across a chunk seam must be continuous: the interpolated
// ramp has no discontinuity where one push ends and the next begins.
static void testResampleContinuityAcrossPushes()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const double src = 96000.0, dev = 48000.0;
// Two chunks big enough that the 2:1 output (~1023 frames) clears the
// prime gate; the seam sits at output frame ~512.
const auto c1 = rampChunk(1024, 0.0f, 1.0f);
const auto c2 = rampChunk(1024, 1024.0f, 1.0f);
bus.push(c1.data(), 1024, src, dev);
bus.push(c2.data(), 1024, src, dev);
std::vector<float> dl(768), dr(768);
assert(bus.pull(dl.data(), dr.data(), 768) == 768);
for (int i = 1; i < 768; ++i)
{
const float d = dl[(size_t) i] - dl[(size_t) i - 1];
// A linear ramp resampled 2:1 must step by ~2 everywhere, including
// across the seam at output frame ~128.
assert(std::fabs(d - 2.0f) < 1e-3f && "discontinuity at chunk seam");
}
}
// Prime gate: nothing comes out until ~kPrimeFrames are buffered.
static void testPrimeGate()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
std::vector<float> dl(64), dr(64);
const auto tiny = rampChunk(RendererBus::kPrimeFrames / 2, 1.0f, 0.0f);
bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0);
assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "must gate until primed");
bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0);
// Cushion built (minus the 1-frame carry per push) — next pull flows.
bus.push(tiny.data(), RendererBus::kPrimeFrames / 2, 48000.0, 48000.0);
assert(bus.pull(dl.data(), dr.data(), 64) == 64);
}
// Underflow: whole-block silence, buffered tail dropped, back to priming.
static void testUnderflowReprimes()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const auto chunk = rampChunk(RendererBus::kPrimeFrames + 64, 1.0f, 0.0f);
bus.push(chunk.data(), RendererBus::kPrimeFrames + 64, 48000.0, 48000.0);
std::vector<float> dl(512), dr(512);
assert(bus.pull(dl.data(), dr.data(), 512) == 512);
// Ring now nearly empty → this pull underflows.
assert(bus.pull(dl.data(), dr.data(), 512) == 0);
assert(bus.metrics().underflowCount == 1);
// And the gate re-armed: a sub-prime refill still gates.
const auto tiny = rampChunk(64, 1.0f, 0.0f);
bus.push(tiny.data(), 64, 48000.0, 48000.0);
assert(bus.pull(dl.data(), dr.data(), 32) == 0 && "must re-prime after underflow");
}
// Fill clamp: a dumped backlog beyond kMaxFillFrames is trimmed to the prime
// target instead of being played ~85 ms late.
static void testFillClampTrimsBacklog()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const int backlog = RendererBus::kMaxFillFrames + 2048;
const auto chunk = rampChunk(backlog + 1, 1.0f, 0.0f);
bus.push(chunk.data(), backlog + 1, 48000.0, 48000.0);
std::vector<float> dl(256), dr(256);
assert(bus.pull(dl.data(), dr.data(), 256) == 256);
const auto m = bus.metrics();
assert(m.overflowCount == 1 && "fill clamp must count as overflow");
assert(m.fillFrames <= RendererBus::kPrimeFrames && "backlog must be trimmed to prime target");
}
// Disabled bus: push and pull are inert.
static void testDisabledIsInert()
{
RendererBus bus;
const auto chunk = rampChunk(128, 1.0f, 0.0f);
assert(!bus.push(chunk.data(), 128, 48000.0, 48000.0));
std::vector<float> dl(64), dr(64);
assert(bus.pull(dl.data(), dr.data(), 64) == 0);
assert(!bus.metrics().enabled);
}
// Gain is applied consumer-side and sanitized (0..8, non-finite → 0).
static void testGainApplied()
{
RendererBus bus;
bus.setEnabled(true, 2.0f);
const auto chunk = rampChunk(RendererBus::kPrimeFrames + 65, 1.0f, 0.0f);
bus.push(chunk.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0);
std::vector<float> dl(64), dr(64);
assert(bus.pull(dl.data(), dr.data(), 64) == 64);
assert(dl[0] == 2.0f && dr[0] == -2.0f);
}
// Disable drops the buffered tail — via the consumer-honored flush flag
// (deep-read §4 fix), so a re-enable never replays stale audio.
static void testFlushOnDisable()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const auto chunk = rampChunk(RendererBus::kPrimeFrames * 2, 5.0f, 0.0f);
bus.push(chunk.data(), RendererBus::kPrimeFrames * 2, 48000.0, 48000.0);
bus.setEnabled(false, 1.0f); // requests the flush; consumer performs it
bus.setEnabled(true, 1.0f);
std::vector<float> dl(64), dr(64);
// First pull consumes the flush: the pre-disable tail is gone, so the bus
// is empty and (re-)priming — nothing plays.
assert(bus.pull(dl.data(), dr.data(), 64) == 0 && "stale tail must not replay");
assert(bus.metrics().fillFrames == 0 && "flush must drop the buffered tail");
// Fresh audio after the re-enable flows once primed.
const auto fresh = rampChunk(RendererBus::kPrimeFrames + 65, 7.0f, 0.0f);
bus.push(fresh.data(), RendererBus::kPrimeFrames + 65, 48000.0, 48000.0);
assert(bus.pull(dl.data(), dr.data(), 64) == 64);
// Frame 0 is the resampler's one-frame interpolation carry (by design);
// everything after must be the fresh push, not the flushed 5.0 tail.
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.
// A bad sourceRate falls back to deviceRate (documented behaviour).
static void testRejectsUnusableRates()
{
RendererBus bus;
bus.setEnabled(true, 1.0f);
const auto chunk = rampChunk(128, 1.0f, 0.0f);
const double nan = std::nan("");
const double inf = std::numeric_limits<double>::infinity();
assert(!bus.push(chunk.data(), 128, 48000.0, nan));
assert(!bus.push(chunk.data(), 128, 48000.0, inf));
assert(!bus.push(chunk.data(), 128, 48000.0, -48000.0));
assert(!bus.push(chunk.data(), 128, 48000.0, 0.0));
// step underflow: denormal source over huge device rate → step == 0.
assert(!bus.push(chunk.data(), 128, 5e-324, 1e308));
assert(bus.metrics().pushedFrames == 0 && "rejected pushes must stage nothing");
// NaN/Inf/negative SOURCE rate falls back to deviceRate (step == 1).
assert(bus.push(chunk.data(), 128, nan, 48000.0));
assert(bus.push(chunk.data(), 128, inf, 48000.0));
assert(bus.push(chunk.data(), 128, -1.0, 48000.0));
assert(bus.metrics().pushedFrames > 0);
}
int main()
{
testEqualRateBitExact();
testRejectsUnusableRates();
testResampleContinuityAcrossPushes();
testPrimeGate();
testUnderflowReprimes();
testFillClampTrimsBacklog();
testDisabledIsInert();
testGainApplied();
testFlushOnDisable();
testFlushSparesPostReEnableAudio();
std::puts("renderer_bus: all cases passed");
return 0;
}