diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index d0fba8f..ecb060f 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -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& 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; } diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 4933777..81e580c 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -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 @@ -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 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 rendererBusRing; - std::atomic rendererBusPushedFrames{0}; - std::atomic rendererBusConsumedFrames{0}; - std::atomic rendererBusUnderflowCount{0}; - std::atomic rendererBusOverflowCount{0}; - std::atomic rendererBusEnabled{false}; - std::atomic 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 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 diff --git a/src/audio/engine/RendererBus.h b/src/audio/engine/RendererBus.h new file mode 100644 index 0000000..a01c6c8 --- /dev/null +++ b/src/audio/engine/RendererBus.h @@ -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 +#include +#include + +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 ring; + std::atomic pushedFrames{0}; + std::atomic consumedFrames{0}; + std::atomic underflowCount{0}; + std::atomic overflowCount{0}; + std::atomic busEnabled{false}; + std::atomic 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 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 diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index b56b047..6eca508 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -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) diff --git a/tests/engine_units/renderer_bus_test.cpp b/tests/engine_units/renderer_bus_test.cpp new file mode 100644 index 0000000..51cbf82 --- /dev/null +++ b/tests/engine_units/renderer_bus_test.cpp @@ -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 +#include +#include +#include + +using slopsmith::RendererBus; + +static std::vector rampChunk(int frames, float start, float step) +{ + std::vector 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 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 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 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 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 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 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 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; +}