mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 23:04:09 +00:00
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:
co-authored by
Claude Fable 5
parent
eb40b87dea
commit
70f3316094
+11
-109
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user