refactor(audio): extract RendererBus (phase 2)

Moves the WebAudio→engine bus — ring, producer-side linear resampler,
prefill gate, fill clamp, metrics — verbatim into
src/audio/engine/RendererBus.h. AudioEngine keeps thin facades
(setRendererBus/pushRendererAudio/pullRendererBus/getRendererBusMetrics) so
the NodeAddon surface is unchanged. JUCE-free: pull() takes raw channel
pointers, which is what lets tests/engine_units drive the resampler
continuity, prime/underflow/clamp, and metrics cases without a device.

The control-thread readIndex write on disable (deep-read §4) is preserved
verbatim and marked; its flush-flag fix lands as the phase-8 commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-13 23:46:55 +02:00
co-authored by Claude Fable 5
parent eb40b87dea
commit 70f3316094
5 changed files with 380 additions and 152 deletions
+11 -109
View File
@@ -3039,126 +3039,28 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate)
{
if (!rendererBusEnabled.load(std::memory_order_acquire)) return false;
if (interleavedLR == nullptr || frames <= 0) return false;
const double deviceRate = getCurrentSampleRate();
if (deviceRate <= 0.0) return false;
if (!(sourceRate > 0.0)) sourceRate = deviceRate;
uint64_t w = rendererBusRing.beginWrite();
// Linear resample source→device rate on this (IPC) thread. `pos` is the
// fractional read position into the incoming chunk; index -1 refers to the
// carried last frame of the previous chunk so interpolation is continuous
// across pushes. Equal rates degenerate to step == 1.0 (still exact:
// pos stays integral, frac == 0).
const double step = sourceRate / deviceRate;
double pos = rendererBusSrcPos;
uint64_t written = 0;
while (true)
{
const double ip = std::floor(pos);
const int i0 = (int) ip;
if (i0 + 1 >= frames) break; // next chunk continues from here
const float frac = (float) (pos - ip);
const float l0 = (i0 < 0) ? rendererBusPrevL : interleavedLR[(size_t) i0 * 2];
const float r0 = (i0 < 0) ? rendererBusPrevR : interleavedLR[(size_t) i0 * 2 + 1];
const float l1 = interleavedLR[((size_t) i0 + 1) * 2];
const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1];
rendererBusRing.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac);
++w;
++written;
pos += step;
}
rendererBusSrcPos = pos - (double) frames; // relative to the next chunk
rendererBusPrevL = interleavedLR[((size_t) frames - 1) * 2];
rendererBusPrevR = interleavedLR[((size_t) frames - 1) * 2 + 1];
// Publish. Overflow (producer lapping the consumer) is handled consumer-
// side with drop-oldest — same contract as the extra-input rings — so only
// the consumer ever moves readIndex.
rendererBusRing.publish(w);
rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed);
return true;
// Producer-side resample + publish live on RendererBus (engine/RendererBus.h).
return rendererBus.push(interleavedLR, frames, sourceRate, getCurrentSampleRate());
}
int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
{
if (!rendererBusEnabled.load(std::memory_order_acquire)) return 0;
// Cold start before about-to-start sized the scratch — skip, never alloc
// on the RT thread (same rule as the stream scratches).
if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0;
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
uint64_t r = rendererBusRing.readIndex.load(std::memory_order_relaxed);
if (w - r > (uint64_t) kRendererBusFrames)
{
// Producer lapped us — drop-oldest to the newest full ring.
r = w - (uint64_t) kRendererBusFrames;
rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
uint64_t avail = w - r;
// Fill clamp (spike finding): steady-state drift is near zero, so a fill
// beyond kRendererBusMaxFill only ever means a renderer stall dumped a
// backlog. Trim to the prime target instead of playing the whole tail at
// ~85+ ms behind — a latency reset, not an audible gap.
if (avail > (uint64_t) kRendererBusMaxFillFrames)
{
r = w - (uint64_t) kRendererBusPrimeFrames;
avail = (uint64_t) kRendererBusPrimeFrames;
rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
// Prefill gate (spike finding): the warmup underflow burst is the mix
// starting before the ring has a cushion. Consume nothing until the
// producer has built ~10 ms; re-arm the same gate after a real underflow
// so stall recovery is one clean gap, not a ragged refill.
if (!rendererBusPrimed)
{
if (avail < (uint64_t) kRendererBusPrimeFrames)
{
rendererBusRing.commitRead(r);
return 0;
}
rendererBusPrimed = true;
}
if (avail < (uint64_t) numSamples)
{
// Underflow: emit silence for the whole block (partial blocks blip),
// drop what's buffered, and go back to priming.
rendererBusPrimed = false;
rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed);
rendererBusRing.commitRead(w);
return 0;
}
const int pull = numSamples;
const float g = rendererBusGain.load(std::memory_order_relaxed);
float* dl = dest.getWritePointer(0);
float* dr = dest.getWritePointer(1);
for (int i = 0; i < pull; ++i)
{
float l, rr;
rendererBusRing.readFrame(r + (uint64_t) i, l, rr);
dl[i] = l * g;
dr[i] = rr * g;
}
rendererBusRing.commitRead(r + (uint64_t) pull);
rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed);
return pull;
return rendererBus.pull(dest.getWritePointer(0), dest.getWritePointer(1), numSamples);
}
AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
{
const auto bm = rendererBus.metrics();
RendererBusMetrics m;
m.pushedFrames = rendererBusPushedFrames.load(std::memory_order_relaxed);
m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed);
m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed);
m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed);
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
const uint64_t r = rendererBusRing.readIndex.load(std::memory_order_acquire);
m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames);
m.capacityFrames = kRendererBusFrames;
m.enabled = rendererBusEnabled.load(std::memory_order_relaxed);
m.pushedFrames = bm.pushedFrames;
m.consumedFrames = bm.consumedFrames;
m.underflowCount = bm.underflowCount;
m.overflowCount = bm.overflowCount;
m.fillFrames = bm.fillFrames;
m.capacityFrames = bm.capacityFrames;
m.enabled = bm.enabled;
return m;
}
+4 -43
View File
@@ -3,6 +3,7 @@
#include "GainSanitize.h"
#include "engine/PackedStereoRing.h"
#include "engine/EngineState.h"
#include "engine/RendererBus.h"
#include "BackingLeveler.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
@@ -270,20 +271,7 @@ public:
// mixer path is silenced. SPSC: producer is the main-process IPC thread,
// consumer is whichever output callback is live (duplex or split). Default
// off → zero behaviour change.
void setRendererBus(bool enabled, float gain)
{
rendererBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
const bool was = rendererBusEnabled.exchange(enabled, std::memory_order_acq_rel);
if (was && !enabled)
{
// Drop buffered audio on disable so a later re-enable starts fresh
// instead of playing a stale tail. Consumer tolerates the jump.
rendererBusRing.readIndex.store(
rendererBusRing.writeIndex.load(std::memory_order_acquire),
std::memory_order_release);
rendererBusPrimed.store(false, std::memory_order_relaxed);
}
}
void setRendererBus(bool enabled, float gain) { rendererBus.setEnabled(enabled, gain); }
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
// rate on the producer thread (fractional position + previous frame carried
// across calls). Returns false when the bus is disabled or the engine is
@@ -563,35 +551,8 @@ private:
static constexpr int kOutputRingFrames = 4096;
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
// ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ───────
// Same packed-LR SPSC design as outputRing. Sized generously
// (~1.5 s @ 48 kHz — vs outputRing's 85 ms) because the producer is
// an IPC thread with scheduling jitter, not another audio callback; the
// consumer trims steady-state fill via the drift clamp in the mix step.
static constexpr int kRendererBusFrames = 65536;
static_assert((kRendererBusFrames & (kRendererBusFrames - 1)) == 0,
"kRendererBusFrames must be a power of two for mask wraparound");
// Prefill gate: consume nothing until the producer has built this cushion
// (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is
// one clean gap. Fill clamp: fill beyond this (~85 ms) means a renderer
// stall dumped a backlog — trim to the prime target, don't play the tail.
static constexpr int kRendererBusPrimeFrames = 512;
static constexpr int kRendererBusMaxFillFrames = 4096;
slopsmith::PackedStereoRing<kRendererBusFrames> rendererBusRing;
std::atomic<uint64_t> rendererBusPushedFrames{0};
std::atomic<uint64_t> rendererBusConsumedFrames{0};
std::atomic<uint64_t> rendererBusUnderflowCount{0};
std::atomic<uint64_t> rendererBusOverflowCount{0};
std::atomic<bool> rendererBusEnabled{false};
std::atomic<float> rendererBusGain{1.0f};
// Consumer-side prefill-gate state. Only the live output callback touches
// it, but duplex/split hand-offs cross threads — atomic keeps that safe.
std::atomic<bool> rendererBusPrimed{false};
// Producer-thread-only linear-resampler state (fractional read position
// into the incoming chunk + the previous chunk's last frame for
// interpolation continuity across pushes).
double rendererBusSrcPos = 0.0;
float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f;
// ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2)
slopsmith::RendererBus rendererBus;
// Shared consumer step for the duplex and split output paths: drain one
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
// dest cleared first). Returns numSamples on success, 0 when gated
+206
View File
@@ -0,0 +1,206 @@
#pragma once
// RendererBus — the WebAudio→engine audio bus (TLC plan phase 2 / §2.6).
// Moved verbatim from AudioEngine (see git history for the original inline
// comments' evolution): the renderer pushes its WebAudio master mix here over
// IPC so song/stem audio stays audible when the output device is
// exclusive-style (ASIO / WASAPI exclusive) and the OS mixer path is silent.
//
// SPSC: producer is the main-process IPC thread (push — includes the linear
// resampler), consumer is whichever output callback is live (pull). Sized
// generously (~1.5 s @ 48 kHz) because the producer has scheduling jitter;
// the consumer trims steady-state fill via the fill clamp.
//
// JUCE-free on purpose: pull() takes raw channel pointers, so
// tests/engine_units drives the resampler/prime/clamp logic without a device.
#include "PackedStereoRing.h"
#include "../GainSanitize.h"
#include <atomic>
#include <cmath>
#include <cstdint>
namespace slopsmith {
class RendererBus
{
public:
static constexpr int kFrames = 65536;
// Prefill gate: consume nothing until the producer has built this cushion
// (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is
// one clean gap. Fill clamp: fill beyond kMaxFillFrames (~85 ms) means a
// renderer stall dumped a backlog — trim to the prime target, don't play
// the tail.
static constexpr int kPrimeFrames = 512;
static constexpr int kMaxFillFrames = 4096;
void setEnabled(bool enabled, float gain)
{
busGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
const bool was = busEnabled.exchange(enabled, std::memory_order_acq_rel);
if (was && !enabled)
{
// Drop buffered audio on disable so a later re-enable starts fresh
// instead of playing a stale tail. Consumer tolerates the jump.
// KNOWN ISSUE (deep-read §4, fixed in the follow-up commit): this
// writes readIndex from the control thread while pull() is the
// designated consumer-side writer.
ring.readIndex.store(ring.writeIndex.load(std::memory_order_acquire),
std::memory_order_release);
primed.store(false, std::memory_order_relaxed);
}
}
bool isEnabled() const { return busEnabled.load(std::memory_order_relaxed); }
// Interleaved stereo frames at `sourceRate`, linear-resampled to
// `deviceRate` on the producer thread (fractional position + previous
// frame carried across calls). Returns false when the bus is disabled or
// the rates are unusable. Drop-oldest on overflow, counted consumer-side.
bool push(const float* interleavedLR, int frames, double sourceRate, double deviceRate)
{
if (!busEnabled.load(std::memory_order_acquire)) return false;
if (interleavedLR == nullptr || frames <= 0) return false;
if (deviceRate <= 0.0) return false;
if (!(sourceRate > 0.0)) sourceRate = deviceRate;
uint64_t w = ring.beginWrite();
// Linear resample source→device rate on this (IPC) thread. `pos` is
// the fractional read position into the incoming chunk; index -1
// refers to the carried last frame of the previous chunk so
// interpolation is continuous across pushes. Equal rates degenerate
// to step == 1.0 (still exact: pos stays integral, frac == 0).
const double step = sourceRate / deviceRate;
double pos = srcPos;
uint64_t written = 0;
while (true)
{
const double ip = std::floor(pos);
const int i0 = (int) ip;
if (i0 + 1 >= frames) break; // next chunk continues from here
const float frac = (float) (pos - ip);
const float l0 = (i0 < 0) ? prevL : interleavedLR[(size_t) i0 * 2];
const float r0 = (i0 < 0) ? prevR : interleavedLR[(size_t) i0 * 2 + 1];
const float l1 = interleavedLR[((size_t) i0 + 1) * 2];
const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1];
ring.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac);
++w;
++written;
pos += step;
}
srcPos = pos - (double) frames; // relative to the next chunk
prevL = interleavedLR[((size_t) frames - 1) * 2];
prevR = interleavedLR[((size_t) frames - 1) * 2 + 1];
// Publish. Overflow (producer lapping the consumer) is handled
// consumer-side with drop-oldest — only the consumer moves readIndex.
ring.publish(w);
pushedFrames.fetch_add(written, std::memory_order_relaxed);
return true;
}
// Drain one block into dl/dr (bus gain applied). Returns numSamples on
// success, 0 when gated (disabled, priming, underflow). Single consumer —
// call exactly once per output block.
int pull(float* dl, float* dr, int numSamples)
{
if (!busEnabled.load(std::memory_order_acquire)) return 0;
const uint64_t w = ring.writeIndex.load(std::memory_order_acquire);
uint64_t r = ring.readIndex.load(std::memory_order_relaxed);
if (w - r > (uint64_t) kFrames)
{
// Producer lapped us — drop-oldest to the newest full ring.
r = w - (uint64_t) kFrames;
overflowCount.fetch_add(1, std::memory_order_relaxed);
}
uint64_t avail = w - r;
// Fill clamp (spike finding): steady-state drift is near zero, so a
// fill beyond kMaxFillFrames only ever means a renderer stall dumped a
// backlog. Trim to the prime target instead of playing the whole tail
// at ~85+ ms behind — a latency reset, not an audible gap.
if (avail > (uint64_t) kMaxFillFrames)
{
r = w - (uint64_t) kPrimeFrames;
avail = (uint64_t) kPrimeFrames;
overflowCount.fetch_add(1, std::memory_order_relaxed);
}
// Prefill gate (spike finding): the warmup underflow burst is the mix
// starting before the ring has a cushion. Consume nothing until the
// producer has built ~10 ms; re-arm the same gate after a real
// underflow so stall recovery is one clean gap, not a ragged refill.
if (!primed)
{
if (avail < (uint64_t) kPrimeFrames)
{
ring.commitRead(r);
return 0;
}
primed = true;
}
if (avail < (uint64_t) numSamples)
{
// Underflow: emit silence for the whole block (partial blocks
// blip), drop what's buffered, and go back to priming.
primed = false;
underflowCount.fetch_add(1, std::memory_order_relaxed);
ring.commitRead(w);
return 0;
}
const float g = busGain.load(std::memory_order_relaxed);
for (int i = 0; i < numSamples; ++i)
{
float l, rr;
ring.readFrame(r + (uint64_t) i, l, rr);
dl[i] = l * g;
dr[i] = rr * g;
}
ring.commitRead(r + (uint64_t) numSamples);
consumedFrames.fetch_add((uint64_t) numSamples, std::memory_order_relaxed);
return numSamples;
}
struct Metrics
{
uint64_t pushedFrames = 0, consumedFrames = 0, underflowCount = 0, overflowCount = 0;
int fillFrames = 0, capacityFrames = 0;
bool enabled = false;
};
Metrics metrics() const
{
Metrics m;
m.pushedFrames = pushedFrames.load(std::memory_order_relaxed);
m.consumedFrames = consumedFrames.load(std::memory_order_relaxed);
m.underflowCount = underflowCount.load(std::memory_order_relaxed);
m.overflowCount = overflowCount.load(std::memory_order_relaxed);
const uint64_t w = ring.writeIndex.load(std::memory_order_acquire);
const uint64_t r = ring.readIndex.load(std::memory_order_acquire);
const uint64_t fill = w - r;
m.fillFrames = (int) (fill < (uint64_t) kFrames ? fill : (uint64_t) kFrames);
m.capacityFrames = kFrames;
m.enabled = busEnabled.load(std::memory_order_relaxed);
return m;
}
private:
PackedStereoRing<kFrames> ring;
std::atomic<uint64_t> pushedFrames{0};
std::atomic<uint64_t> consumedFrames{0};
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> busEnabled{false};
std::atomic<float> busGain{1.0f};
// Consumer-side prefill-gate state. Only the live output callback touches
// it, but duplex/split hand-offs cross threads — atomic keeps that safe.
std::atomic<bool> primed{false};
// Producer-thread-only linear-resampler state (fractional read position
// into the incoming chunk + the previous chunk's last frame for
// interpolation continuity across pushes).
double srcPos = 0.0;
float prevL = 0.0f, prevR = 0.0f;
};
} // namespace slopsmith
+4
View File
@@ -16,3 +16,7 @@ add_test(NAME packed_stereo_ring COMMAND packed_stereo_ring_test)
add_executable(engine_state_test engine_state_test.cpp)
target_compile_features(engine_state_test PRIVATE cxx_std_17)
add_test(NAME engine_state COMMAND engine_state_test)
add_executable(renderer_bus_test renderer_bus_test.cpp)
target_compile_features(renderer_bus_test PRIVATE cxx_std_20)
add_test(NAME renderer_bus COMMAND renderer_bus_test)
+155
View File
@@ -0,0 +1,155 @@
// 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 <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);
}
int main()
{
testEqualRateBitExact();
testResampleContinuityAcrossPushes();
testPrimeGate();
testUnderflowReprimes();
testFillClampTrimsBacklog();
testDisabledIsInert();
testGainApplied();
std::puts("renderer_bus: all cases passed");
return 0;
}