mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 03:44:10 +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)
|
bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate)
|
||||||
{
|
{
|
||||||
if (!rendererBusEnabled.load(std::memory_order_acquire)) return false;
|
// Producer-side resample + publish live on RendererBus (engine/RendererBus.h).
|
||||||
if (interleavedLR == nullptr || frames <= 0) return false;
|
return rendererBus.push(interleavedLR, frames, sourceRate, getCurrentSampleRate());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
|
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
|
// Cold start before about-to-start sized the scratch — skip, never alloc
|
||||||
// on the RT thread (same rule as the stream scratches).
|
// on the RT thread (same rule as the stream scratches).
|
||||||
if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0;
|
if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0;
|
||||||
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
|
return rendererBus.pull(dest.getWritePointer(0), dest.getWritePointer(1), numSamples);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
|
AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
|
||||||
{
|
{
|
||||||
|
const auto bm = rendererBus.metrics();
|
||||||
RendererBusMetrics m;
|
RendererBusMetrics m;
|
||||||
m.pushedFrames = rendererBusPushedFrames.load(std::memory_order_relaxed);
|
m.pushedFrames = bm.pushedFrames;
|
||||||
m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed);
|
m.consumedFrames = bm.consumedFrames;
|
||||||
m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed);
|
m.underflowCount = bm.underflowCount;
|
||||||
m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed);
|
m.overflowCount = bm.overflowCount;
|
||||||
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
|
m.fillFrames = bm.fillFrames;
|
||||||
const uint64_t r = rendererBusRing.readIndex.load(std::memory_order_acquire);
|
m.capacityFrames = bm.capacityFrames;
|
||||||
m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames);
|
m.enabled = bm.enabled;
|
||||||
m.capacityFrames = kRendererBusFrames;
|
|
||||||
m.enabled = rendererBusEnabled.load(std::memory_order_relaxed);
|
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-43
@@ -3,6 +3,7 @@
|
|||||||
#include "GainSanitize.h"
|
#include "GainSanitize.h"
|
||||||
#include "engine/PackedStereoRing.h"
|
#include "engine/PackedStereoRing.h"
|
||||||
#include "engine/EngineState.h"
|
#include "engine/EngineState.h"
|
||||||
|
#include "engine/RendererBus.h"
|
||||||
#include "BackingLeveler.h"
|
#include "BackingLeveler.h"
|
||||||
#include "signalsmith-stretch.h"
|
#include "signalsmith-stretch.h"
|
||||||
#include <juce_audio_devices/juce_audio_devices.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,
|
// mixer path is silenced. SPSC: producer is the main-process IPC thread,
|
||||||
// consumer is whichever output callback is live (duplex or split). Default
|
// consumer is whichever output callback is live (duplex or split). Default
|
||||||
// off → zero behaviour change.
|
// off → zero behaviour change.
|
||||||
void setRendererBus(bool enabled, float gain)
|
void setRendererBus(bool enabled, float gain) { rendererBus.setEnabled(enabled, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
|
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
|
||||||
// rate on the producer thread (fractional position + previous frame carried
|
// rate on the producer thread (fractional position + previous frame carried
|
||||||
// across calls). Returns false when the bus is disabled or the engine is
|
// across calls). Returns false when the bus is disabled or the engine is
|
||||||
@@ -563,35 +551,8 @@ private:
|
|||||||
static constexpr int kOutputRingFrames = 4096;
|
static constexpr int kOutputRingFrames = 4096;
|
||||||
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
|
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
|
||||||
|
|
||||||
// ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ───────
|
// ── Renderer-audio bus (see engine/RendererBus.h — moved in TLC phase 2)
|
||||||
// Same packed-LR SPSC design as outputRing. Sized generously
|
slopsmith::RendererBus rendererBus;
|
||||||
// (~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;
|
|
||||||
// Shared consumer step for the duplex and split output paths: drain one
|
// Shared consumer step for the duplex and split output paths: drain one
|
||||||
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
|
// block from the renderer-bus ring into `dest` (stereo, bus gain applied,
|
||||||
// dest cleared first). Returns numSamples on success, 0 when gated
|
// 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)
|
add_executable(engine_state_test engine_state_test.cpp)
|
||||||
target_compile_features(engine_state_test PRIVATE cxx_std_17)
|
target_compile_features(engine_state_test PRIVATE cxx_std_17)
|
||||||
add_test(NAME engine_state COMMAND engine_state_test)
|
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