feat(audio): engine-owned Mixer — RendererBus generalized to N channels

Phase B core (plan §5/§8):
- engine/Mixer.h: lock-free slot state machine (Free→Active→Draining→Free),
  channel #0 = the RendererBus byte-compatible permanent default, lazy
  ~512KB ring allocation per claimed slot (high-water, never freed under a
  live audio thread), per-channel gain/mute with block ramps (click-free),
  fade-to-silence reclaim (§5), hard cap 24 with no-capacity refusal
  (§8.9), group start gates at block granularity (§8.13), native gain
  clamp via sanitizeStreamGain (§5.1 tier 2).
- AudioEngine: mixer replaces the bare rendererBus member;
  setRendererBus/pushRendererAudio/getRendererBusMetrics delegate to
  channel #0 unchanged; pullRendererBus now mixes all ready channels
  (duplex + split paths and the StreamSink submix carry every channel).
- N-API: mixerCreateChannel/Release/Push/SetGain/SetMute/SetGroup/List.
- tests/engine_units/mixer_test.cpp: byte-compat vs plain RendererBus,
  cap refusal, live-consumer fade-then-free, ramp clamps, group gate,
  bounded strings. renderer_bus_test still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 22:27:08 +02:00
co-authored by Claude Fable 5
parent 5ecf55cb0c
commit b51f88a574
8 changed files with 754 additions and 19 deletions
+17 -7
View File
@@ -270,7 +270,7 @@ AudioEngine::LatencyBreakdown AudioEngine::getLatencyBreakdown() const
// Song audio over the renderer bus is delayed by the measured bus fill —
// a term no previous latency figure surfaced (deep-read 5).
const auto busMetrics = rendererBus.metrics();
const auto busMetrics = mixer.defaultBus().metrics();
if (busMetrics.enabled)
b.rendererBusMs = ms((double) busMetrics.fillFrames);
return b;
@@ -815,6 +815,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device)
if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true);
streamSink.prepareProducerScratch();
if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true);
if (mixerChannelScratch.getNumSamples() < streamScratchCap) mixerChannelScratch.setSize(2, streamScratchCap, false, false, true);
// Prepare each ACTIVE PRIMARY-device source's DSP and reset its rings for a
// clean cold start. Inactive pooled chains stay unprepared (no threads). EXTRA-
@@ -881,6 +882,7 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device)
if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true);
streamSink.prepareProducerScratch();
if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true);
if (mixerChannelScratch.getNumSamples() < streamScratchCap) mixerChannelScratch.setSize(2, streamScratchCap, false, false, true);
// NOTE: outputBackingBuffer is sized by audioDeviceAboutToStart() from the
// INPUT device's block size — it's the split-input DSP scratch, not an
// output-side buffer. Don't touch it here: resizing from the output
@@ -1305,21 +1307,29 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate)
{
// Producer-side resample + publish live on RendererBus (engine/RendererBus.h).
return rendererBus.push(interleavedLR, frames, sourceRate, getCurrentSampleRate());
// Producer-side resample + publish live on channel #0's bus (the
// renderer-master default channel — engine/Mixer.h).
return mixer.defaultBus().push(interleavedLR, frames, sourceRate, getCurrentSampleRate());
}
int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
{
// Cold start before about-to-start sized the scratch — skip, never alloc
// on the RT thread (same rule as the stream scratches).
// Cold start before about-to-start sized the scratches — skip, never
// alloc on the RT thread (same rule as the stream scratches).
if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0;
return rendererBus.pull(dest.getWritePointer(0), dest.getWritePointer(1), numSamples);
if (mixerChannelScratch.getNumSamples() < numSamples || mixerChannelScratch.getNumChannels() < 2) return 0;
dest.clear(0, 0, numSamples);
dest.clear(1, 0, numSamples);
const int contributed = mixer.pullMixInto(dest.getWritePointer(0), dest.getWritePointer(1),
numSamples,
mixerChannelScratch.getWritePointer(0),
mixerChannelScratch.getWritePointer(1));
return contributed > 0 ? numSamples : 0;
}
AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
{
const auto bm = rendererBus.metrics();
const auto bm = mixer.defaultBus().metrics();
RendererBusMetrics m;
m.pushedFrames = bm.pushedFrames;
m.consumedFrames = bm.consumedFrames;
+35 -12
View File
@@ -4,6 +4,7 @@
#include "engine/PackedStereoRing.h"
#include "engine/EngineState.h"
#include "engine/RendererBus.h"
#include "engine/Mixer.h"
#include "engine/StreamSink.h"
#include "engine/BackingPlayer.h"
#include "engine/DeviceSetup.h"
@@ -257,7 +258,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) { rendererBus.setEnabled(enabled, gain); }
void setRendererBus(bool enabled, float gain) { mixer.defaultBus().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
@@ -271,6 +272,24 @@ public:
};
RendererBusMetrics getRendererBusMetrics() const;
// ── Mixer channel API (ownership plan §5.1, tiers 13). Control-thread
// calls; the audio thread only ever mixes. Channel #0 is the renderer bus
// above; these manage the bespoke channels. All bounds/values are
// sanitized natively (tier-2 rule: no JS-side trust).
int mixerCreateChannel(const char* label, const char* kind, const char* holder)
{
return mixer.createChannel(label, kind, holder, isAudioRunning());
}
bool mixerReleaseChannel(int id) { return mixer.releaseChannel(id, isAudioRunning()); }
bool mixerPushChannel(int id, const float* interleavedLR, int frames, double sourceRate)
{
return mixer.pushChannel(id, interleavedLR, frames, sourceRate, getCurrentSampleRate());
}
bool mixerSetChannelGain(int id, float gain) { return mixer.setChannelGain(id, gain); }
bool mixerSetChannelMute(int id, bool mute) { return mixer.setChannelMute(id, mute); }
bool mixerSetChannelGroup(int id, int group) { return mixer.setChannelGroup(id, group); }
int mixerListChannels(slopsmith::Mixer::ChannelInfo* out) const { return mixer.listChannels(out); }
float getStreamSinkLevel() const { return streamSink.getLevel(); }
uint64_t getStreamUnderflowCount() const { return streamSink.getUnderflowCount(); }
// Producer overflow (drop-oldest): the consumer fell a full ring behind and
@@ -481,19 +500,23 @@ private:
static constexpr int kOutputRingFrames = 4096;
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
// ── 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
// (disabled, priming, underflow, scratch undersized). Single consumer —
// call exactly once per output block; the caller mixes the pulled block
// into the device output AND hands it to composeAndPushStreamMix so the
// streamer submix carries renderer-fed song audio too.
// ── Engine-owned mixer (ownership plan §5). Channel #0 is the renderer
// bus (permanent default, byte-compatible with the pre-mixer surface);
// bespoke channels are producer-requested. See engine/Mixer.h.
slopsmith::Mixer mixer;
// Shared consumer step for the duplex and split output paths: mix one
// block from every ready mixer channel into `dest` (stereo, per-channel
// gain ramped, dest cleared first). Returns numSamples when any channel
// contributed, 0 otherwise. Single consumer — call exactly once per
// output block; the caller mixes the pulled block into the device output
// AND hands it to composeAndPushStreamMix so the streamer submix carries
// renderer-fed song audio too.
int pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples);
// Scratch for the per-block renderer-bus pull. Fixed capacity, sized once
// in about-to-start next to the stream scratches (same no-realloc rule).
// Scratches for the per-block mixer pull: the mixed result + one channel
// pull scratch. Fixed capacity, sized once in about-to-start next to the
// stream scratches (same no-realloc rule).
juce::AudioBuffer<float> rendererBusPullScratch;
juce::AudioBuffer<float> mixerChannelScratch;
std::atomic<uint64_t> outputUnderflowCount{0};
std::atomic<uint64_t> inputOverflowCount{0};
+7
View File
@@ -388,6 +388,13 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
exports.Set("setRendererBus", Napi::Function::New(env, SetRendererBus));
exports.Set("pushRendererAudio", Napi::Function::New(env, PushRendererAudio));
exports.Set("getRendererBusMetrics", Napi::Function::New(env, GetRendererBusMetrics));
exports.Set("mixerCreateChannel", Napi::Function::New(env, MixerCreateChannel));
exports.Set("mixerReleaseChannel", Napi::Function::New(env, MixerReleaseChannel));
exports.Set("mixerPushChannel", Napi::Function::New(env, MixerPushChannel));
exports.Set("mixerSetChannelGain", Napi::Function::New(env, MixerSetChannelGain));
exports.Set("mixerSetChannelMute", Napi::Function::New(env, MixerSetChannelMute));
exports.Set("mixerSetChannelGroup", Napi::Function::New(env, MixerSetChannelGroup));
exports.Set("mixerListChannels", Napi::Function::New(env, MixerListChannels));
exports.Set("getStreamSinkLevel", Napi::Function::New(env, GetStreamSinkLevel));
exports.Set("isStreamOutputActive", Napi::Function::New(env, IsStreamOutputActive));
exports.Set("getStreamUnderflowCount", Napi::Function::New(env, GetStreamUnderflowCount));
+7
View File
@@ -93,6 +93,13 @@ Napi::Value SetPan(const Napi::CallbackInfo& info);
Napi::Value SetParameter(const Napi::CallbackInfo& info);
Napi::Value SetPostGain(const Napi::CallbackInfo& info);
Napi::Value SetRendererBus(const Napi::CallbackInfo& info);
Napi::Value MixerCreateChannel(const Napi::CallbackInfo& info);
Napi::Value MixerReleaseChannel(const Napi::CallbackInfo& info);
Napi::Value MixerPushChannel(const Napi::CallbackInfo& info);
Napi::Value MixerSetChannelGain(const Napi::CallbackInfo& info);
Napi::Value MixerSetChannelMute(const Napi::CallbackInfo& info);
Napi::Value MixerSetChannelGroup(const Napi::CallbackInfo& info);
Napi::Value MixerListChannels(const Napi::CallbackInfo& info);
Napi::Value SetSlotState(const Napi::CallbackInfo& info);
Napi::Value SetSourceChart(const Napi::CallbackInfo& info);
Napi::Value SetSourceInputChannel(const Napi::CallbackInfo& info);
+112
View File
@@ -425,6 +425,118 @@ Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info)
return obj;
}
// ── Mixer channel bindings (ownership plan §5.1, tiers 1–3) ─────────────────
// Channel #0 stays on the setRendererBus / pushRendererAudio surface above;
// these manage bespoke channels. All string/number inputs are bounded and
// sanitized native-side (tier-2 rule).
// mixerCreateChannel(label:string, kind:string, holder:string) -> number (id, -1 = no-capacity)
Napi::Value MixerCreateChannel(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto liveEngine = snapshotEngine();
if (!liveEngine || info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString())
return Napi::Number::New(env, -1);
const std::string label = info[0].As<Napi::String>().Utf8Value();
const std::string kind = info[1].As<Napi::String>().Utf8Value();
const std::string holder = info[2].As<Napi::String>().Utf8Value();
return Napi::Number::New(env, liveEngine->mixerCreateChannel(label.c_str(), kind.c_str(), holder.c_str()));
}
// mixerReleaseChannel(id:number) -> boolean (fade-to-silence reclaim)
Napi::Value MixerReleaseChannel(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
const bool ok = liveEngine && info.Length() >= 1 && info[0].IsNumber()
&& liveEngine->mixerReleaseChannel(info[0].As<Napi::Number>().Int32Value());
return Napi::Boolean::New(info.Env(), ok);
}
// mixerPushChannel(id:number, interleavedLR:Float32Array, sourceRate:number) -> boolean
Napi::Value MixerPushChannel(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto liveEngine = snapshotEngine();
if (!liveEngine || info.Length() < 3 || !info[0].IsNumber() || !info[1].IsTypedArray() || !info[2].IsNumber())
return Napi::Boolean::New(env, false);
auto ta = info[1].As<Napi::TypedArray>();
if (ta.TypedArrayType() != napi_float32_array)
return Napi::Boolean::New(env, false);
auto f32 = info[1].As<Napi::Float32Array>();
const size_t samples = f32.ElementLength();
if (samples < 2)
return Napi::Boolean::New(env, false);
const bool ok = liveEngine->mixerPushChannel(
info[0].As<Napi::Number>().Int32Value(),
f32.Data(), (int) (samples / 2),
info[2].As<Napi::Number>().DoubleValue());
return Napi::Boolean::New(env, ok);
}
// mixerSetChannelGain(id:number, gain:number) -> boolean (native-clamped)
Napi::Value MixerSetChannelGain(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
const bool ok = liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()
&& liveEngine->mixerSetChannelGain(info[0].As<Napi::Number>().Int32Value(),
(float) info[1].As<Napi::Number>().DoubleValue());
return Napi::Boolean::New(info.Env(), ok);
}
// mixerSetChannelMute(id:number, mute:boolean) -> boolean
Napi::Value MixerSetChannelMute(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
const bool ok = liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsBoolean()
&& liveEngine->mixerSetChannelMute(info[0].As<Napi::Number>().Int32Value(),
info[1].As<Napi::Boolean>().Value());
return Napi::Boolean::New(info.Env(), ok);
}
// mixerSetChannelGroup(id:number, group:number) -> boolean (§8.13; -1 = ungroup)
Napi::Value MixerSetChannelGroup(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
const bool ok = liveEngine && info.Length() >= 2 && info[0].IsNumber() && info[1].IsNumber()
&& liveEngine->mixerSetChannelGroup(info[0].As<Napi::Number>().Int32Value(),
info[1].As<Napi::Number>().Int32Value());
return Napi::Boolean::New(info.Env(), ok);
}
// mixerListChannels() -> [{id,label,kind,holder,gain,mute,group,enabled,
// fillFrames,capacityFrames,pushedFrames,consumedFrames,underflowCount,
// overflowCount}] — tier-1 observe surface.
Napi::Value MixerListChannels(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto arr = Napi::Array::New(env);
auto liveEngine = snapshotEngine();
if (!liveEngine) return arr;
slopsmith::Mixer::ChannelInfo channels[slopsmith::Mixer::kMaxChannels];
const int count = liveEngine->mixerListChannels(channels);
for (int i = 0; i < count; ++i)
{
const auto& c = channels[i];
auto obj = Napi::Object::New(env);
obj.Set("id", c.id);
obj.Set("label", c.label);
obj.Set("kind", c.kind);
obj.Set("holder", c.holder);
obj.Set("gain", c.gain);
obj.Set("mute", c.mute);
obj.Set("group", c.group);
obj.Set("enabled", c.metrics.enabled);
obj.Set("fillFrames", c.metrics.fillFrames);
obj.Set("capacityFrames", c.metrics.capacityFrames);
obj.Set("pushedFrames", (double) c.metrics.pushedFrames);
obj.Set("consumedFrames", (double) c.metrics.consumedFrames);
obj.Set("underflowCount", (double) c.metrics.underflowCount);
obj.Set("overflowCount", (double) c.metrics.overflowCount);
arr.Set((uint32_t) i, obj);
}
return arr;
}
// getStreamSinkLevel() -> number (peak 0..1+)
Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info)
{
+336
View File
@@ -0,0 +1,336 @@
#pragma once
// Mixer — the engine-owned channel mixer (docs/audio-ownership-plan.md §5).
// Every audible thing becomes a channel: channel #0 is the permanent default
// (the renderer master via loopback capture — the old RendererBus,
// byte-compatible), bespoke channels are the opt-in upgrade for producers
// that want their own gain/mute/meter/diagnostics.
//
// Threading model (same discipline as RendererBus):
// - producer side (push): main-process IPC thread, per channel (SPSC)
// - consumer side (pullMix): the live output callback, all channels
// - control side (create/release/gain/mute): main-process control thread
//
// Slot lifecycle is a lock-free state machine so the audio thread never
// touches a slot being torn down:
// Free ──create()──▶ Active ──release()──▶ Draining ──audio thread fades
// to silence over one block──▶ Free (or control-side Free when no
// consumer is running). Reclaim is a fade, never a click (§5).
//
// Channel groups (§8.13): a group is a shared START gate at block
// granularity — no member is pulled until every member in the group has
// audio buffered, so grouped producers begin in the same output block.
// Ungrouped channels stay fully independent streams.
//
// JUCE-free on purpose, same as RendererBus: tests/engine_units drives the
// mix/fade/group logic without a device.
#include "RendererBus.h"
#include "../GainSanitize.h"
#include <atomic>
#include <cstdint>
#include <cstring>
#include <memory>
namespace slopsmith {
class Mixer
{
public:
// Hard cap (§8.9): channels are cheap but not free — each is a ring +
// resampler + one mix iteration per callback. Refusal at the cap is
// `no-capacity`; idle reaping lives JS-side on the metrics.
static constexpr int kMaxChannels = 24;
static constexpr int kLabelMax = 64;
static constexpr int kHolderMax = 128;
static constexpr int kKindMax = 16;
enum class SlotState : uint32_t { Free = 0, Active = 1, Draining = 2 };
Mixer()
{
// Channel #0: the permanent default (plan §5 decision) — always
// present, never reaped, never released.
auto& s = slots[0];
s.bus = std::make_unique<RendererBus>();
copyBounded(s.label, kLabelMax, "renderer-master");
copyBounded(s.holder, kHolderMax, "engine:renderer-default");
copyBounded(s.kind, kKindMax, "default");
s.state.store((uint32_t) SlotState::Active, std::memory_order_release);
channelCount.store(1, std::memory_order_relaxed);
}
// The default channel's bus — the byte-compatible RendererBus surface
// (setRendererBus / pushRendererAudio / getRendererBusMetrics delegate
// here so the existing renderer-bus suite passes unchanged).
RendererBus& defaultBus() { return *slots[0].bus; }
const RendererBus& defaultBus() const { return *slots[0].bus; }
// ── control thread ───────────────────────────────────────────────────────
// Returns the channel id, or -1 at the cap (`no-capacity`). `consumerLive`
// tells us whether an output callback exists to complete Draining→Free;
// without one the control thread reclaims Draining slots directly.
int createChannel(const char* label, const char* kind, const char* holder, bool consumerLive)
{
for (int i = 1; i < kMaxChannels; ++i)
{
auto& s = slots[i];
// Reclaim a parked Draining slot when no consumer will ever fade it.
uint32_t draining = (uint32_t) SlotState::Draining;
if (!consumerLive)
s.state.compare_exchange_strong(draining, (uint32_t) SlotState::Free,
std::memory_order_acq_rel);
uint32_t expected = (uint32_t) SlotState::Free;
if (s.state.compare_exchange_strong(expected, (uint32_t) SlotState::Active,
std::memory_order_acq_rel))
{
// Ring allocated lazily on this control thread (~512 KB per
// channel), kept for the process lifetime once claimed
// (high-water): the audio thread may hold a reference between
// our state checks, so a slot's bus is never deallocated. The
// Active store above happens-before any audio-thread read of
// the pointer via the acquire load in pullMixInto.
if (!s.bus) s.bus = std::make_unique<RendererBus>();
copyBounded(s.label, kLabelMax, label);
copyBounded(s.kind, kKindMax, kind);
copyBounded(s.holder, kHolderMax, holder);
s.gain.store(1.0f, std::memory_order_relaxed);
s.mute.store(false, std::memory_order_relaxed);
s.group.store(-1, std::memory_order_relaxed);
s.generation.fetch_add(1, std::memory_order_relaxed);
s.bus->setEnabled(true, 1.0f);
channelCount.fetch_add(1, std::memory_order_relaxed);
return i;
}
}
return -1;
}
// Fade-to-silence release (§5 reclaim). Channel 0 is never releasable.
bool releaseChannel(int id, bool consumerLive)
{
if (id <= 0 || id >= kMaxChannels) return false;
auto& s = slots[id];
uint32_t expected = (uint32_t) SlotState::Active;
if (!s.state.compare_exchange_strong(expected, (uint32_t) SlotState::Draining,
std::memory_order_acq_rel))
return false;
// Producer pushes stop immediately (pushChannel only accepts Active
// slots). The bus stays ENABLED through the drain so the audio thread
// can pull one last block and fade it — disabling here would flush
// the tail and cut mid-waveform (a click, exactly what §5 forbids).
channelCount.fetch_sub(1, std::memory_order_relaxed);
if (!consumerLive)
{
// No output callback running — nothing to fade; flush and free.
s.bus->setEnabled(false, 0.0f);
s.state.store((uint32_t) SlotState::Free, std::memory_order_release);
}
return true;
}
bool setChannelGain(int id, float gain)
{
auto* s = activeSlot(id);
if (s == nullptr) return false;
// Native clamp (§5.1 tier 2 — no JS-side trust), same sanitizer as
// the stream gain path.
s->gain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
return true;
}
bool setChannelMute(int id, bool mute)
{
auto* s = activeSlot(id);
if (s == nullptr) return false;
s->mute.store(mute, std::memory_order_relaxed);
return true;
}
bool setChannelGroup(int id, int group)
{
auto* s = activeSlot(id);
if (s == nullptr) return false;
s->group.store(group < 0 ? -1 : group, std::memory_order_relaxed);
return true;
}
// Producer push for bespoke channels (tier 3). Channel 0 pushes ride the
// legacy pushRendererAudio path onto the same bus.
bool pushChannel(int id, const float* interleavedLR, int frames, double sourceRate, double deviceRate)
{
auto* s = activeSlot(id);
if (s == nullptr) return false;
return s->bus->push(interleavedLR, frames, sourceRate, deviceRate);
}
// ── audio thread ─────────────────────────────────────────────────────────
// Mix every ready channel into dl/dr (adding), using the caller's scratch
// (never allocates). Returns the number of channels that contributed
// audio this block. Per-channel gain is ramped across the block so gain
// steps, mutes, and the draining fade are click-free.
int pullMixInto(float* dl, float* dr, int numSamples, float* scratchL, float* scratchR)
{
if (numSamples <= 0) return 0;
// Group start gates (§8.13): a group is ready once every Active
// member has at least a prime cushion buffered. Bitmask per group id
// 0..31; ids beyond that are treated as ungrouped.
uint32_t groupBlocked = 0;
for (int i = 0; i < kMaxChannels; ++i)
{
auto& s = slots[i];
if (s.state.load(std::memory_order_acquire) != (uint32_t) SlotState::Active) continue;
const int g = s.group.load(std::memory_order_relaxed);
if (g < 0 || g > 31) continue;
if (s.bus->metrics().fillFrames < RendererBus::kPrimeFrames)
groupBlocked |= (1u << g);
}
int contributed = 0;
for (int i = 0; i < kMaxChannels; ++i)
{
auto& s = slots[i];
const uint32_t state = s.state.load(std::memory_order_acquire);
if (state == (uint32_t) SlotState::Free) continue;
if (state == (uint32_t) SlotState::Draining)
{
// One-block fade from the last smoothed gain to zero, then
// disable (flushes the remaining tail) and Free. setEnabled
// from this thread is safe — it only stores atomics, and the
// producer stopped pushing when the slot left Active.
const int n = s.bus->pull(scratchL, scratchR, numSamples);
if (n > 0 && s.smoothedGain > 0.0f)
{
rampInto(dl, dr, scratchL, scratchR, n, s.smoothedGain, 0.0f);
++contributed;
}
s.smoothedGain = 0.0f;
s.bus->setEnabled(false, 0.0f);
s.state.store((uint32_t) SlotState::Free, std::memory_order_release);
continue;
}
const int g = s.group.load(std::memory_order_relaxed);
if (g >= 0 && g <= 31 && (groupBlocked & (1u << g)) != 0)
continue; // group not ready — member keeps buffering
const int n = s.bus->pull(scratchL, scratchR, numSamples);
if (n <= 0)
{
// No audio this block; keep the smoothed gain tracking so a
// later resume doesn't ramp from an ancient value.
s.smoothedGain = targetGainOf(s);
continue;
}
const float target = targetGainOf(s);
rampInto(dl, dr, scratchL, scratchR, n, s.smoothedGain, target);
s.smoothedGain = target;
++contributed;
}
return contributed;
}
// ── diagnostics (any thread) ────────────────────────────────────────────
struct ChannelInfo
{
int id = -1;
char label[kLabelMax] = {};
char kind[kKindMax] = {};
char holder[kHolderMax] = {};
float gain = 1.0f;
bool mute = false;
int group = -1;
RendererBus::Metrics metrics;
};
// Fills `out` (size kMaxChannels), returns count of non-Free channels.
int listChannels(ChannelInfo* out) const
{
int count = 0;
for (int i = 0; i < kMaxChannels; ++i)
{
const auto& s = slots[i];
if (s.state.load(std::memory_order_acquire) == (uint32_t) SlotState::Free) continue;
auto& info = out[count++];
info.id = i;
std::memcpy(info.label, s.label, kLabelMax);
std::memcpy(info.kind, s.kind, kKindMax);
std::memcpy(info.holder, s.holder, kHolderMax);
info.gain = s.gain.load(std::memory_order_relaxed);
info.mute = s.mute.load(std::memory_order_relaxed);
info.group = s.group.load(std::memory_order_relaxed);
info.metrics = s.bus->metrics();
}
return count;
}
int activeChannelCount() const { return channelCount.load(std::memory_order_relaxed); }
private:
struct Slot
{
// Lazily allocated (~512 KB ring) on first claim, then kept for the
// process lifetime — see createChannel. Only slot 0 exists up front.
std::unique_ptr<RendererBus> bus;
std::atomic<uint32_t> state{(uint32_t) SlotState::Free};
std::atomic<float> gain{1.0f};
std::atomic<bool> mute{false};
std::atomic<int> group{-1};
std::atomic<uint32_t> generation{0};
char label[kLabelMax] = {};
char holder[kHolderMax] = {};
char kind[kKindMax] = {};
// Audio-thread-only ramp state (click-free gain steps / mute / fade).
float smoothedGain = 1.0f;
};
Slot* activeSlot(int id)
{
if (id < 0 || id >= kMaxChannels) return nullptr;
auto& s = slots[id];
if (s.state.load(std::memory_order_acquire) != (uint32_t) SlotState::Active) return nullptr;
return &s;
}
static float targetGainOf(const Slot& s)
{
return s.mute.load(std::memory_order_relaxed)
? 0.0f
: s.gain.load(std::memory_order_relaxed);
}
// Add scratch into dl/dr with a linear gain ramp from `from` to `to`
// across the block.
static void rampInto(float* dl, float* dr, const float* sl, const float* sr,
int n, float from, float to)
{
if (n <= 0) return;
const float stepG = (to - from) / (float) n;
float g = from;
for (int i = 0; i < n; ++i)
{
g += stepG;
dl[i] += sl[i] * g;
dr[i] += sr[i] * g;
}
}
static void copyBounded(char* dst, int cap, const char* src)
{
if (src == nullptr) { dst[0] = '\0'; return; }
int i = 0;
for (; i < cap - 1 && src[i] != '\0'; ++i) dst[i] = src[i];
dst[i] = '\0';
}
Slot slots[kMaxChannels];
std::atomic<int> channelCount{0};
};
} // namespace slopsmith
+4
View File
@@ -21,6 +21,10 @@ 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)
add_executable(mixer_test mixer_test.cpp)
target_compile_features(mixer_test PRIVATE cxx_std_20)
add_test(NAME mixer COMMAND mixer_test)
add_executable(rate_match_test rate_match_test.cpp)
target_compile_features(rate_match_test PRIVATE cxx_std_17)
add_test(NAME rate_match COMMAND rate_match_test)
+236
View File
@@ -0,0 +1,236 @@
// Mixer unit tests (docs/audio-ownership-plan.md §5/§8): channel #0 stays
// byte-compatible with the plain RendererBus path, bespoke channel lifecycle
// (create / cap refusal / release-fade / control-side reclaim), per-channel
// gain/mute with click-free ramps, group start gates (§8.13), and the
// list/diagnostics surface.
#include "../../src/audio/engine/Mixer.h"
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
using slopsmith::Mixer;
using slopsmith::RendererBus;
static std::vector<float> constChunk(int frames, float value)
{
std::vector<float> v((size_t) frames * 2, value);
return v;
}
static void testChannelZeroByteCompatible()
{
// The same push/pull sequence through the mixer's default bus must behave
// exactly like a standalone RendererBus (equal-rate path is bit-exact).
Mixer m;
RendererBus reference;
reference.setEnabled(true, 1.0f);
m.defaultBus().setEnabled(true, 1.0f);
auto chunk = constChunk(1024, 0.25f);
assert(reference.push(chunk.data(), 1024, 48000.0, 48000.0));
assert(m.defaultBus().push(chunk.data(), 1024, 48000.0, 48000.0));
float rl[256], rr[256], ml[256], mr[256], sl[256], sr[256];
const int nRef = reference.pull(rl, rr, 256);
// Mixer path: pullMixInto ADDS into a cleared destination.
std::memset(ml, 0, sizeof(ml));
std::memset(mr, 0, sizeof(mr));
const int contributed = m.pullMixInto(ml, mr, 256, sl, sr);
assert(nRef == 256);
assert(contributed == 1);
for (int i = 0; i < 256; ++i)
{
assert(rl[i] == ml[i]);
assert(rr[i] == mr[i]);
}
std::puts("ok: channel #0 byte-compatible with RendererBus");
}
static void testCreateReleaseAndCap()
{
Mixer m;
// Channel 0 exists at construction.
assert(m.activeChannelCount() == 1);
int ids[Mixer::kMaxChannels];
for (int i = 1; i < Mixer::kMaxChannels; ++i)
{
ids[i] = m.createChannel("stems", "plugin", "wc:1#stems", false);
assert(ids[i] == i);
}
// Cap reached (§8.9): refusal, not growth.
assert(m.createChannel("overflow", "plugin", "wc:1#x", false) == -1);
assert(m.activeChannelCount() == Mixer::kMaxChannels);
// Channel 0 is never releasable.
assert(!m.releaseChannel(0, false));
// Control-side reclaim (no consumer running): release frees immediately.
assert(m.releaseChannel(ids[1], false));
assert(m.activeChannelCount() == Mixer::kMaxChannels - 1);
const int reused = m.createChannel("metronome", "plugin", "wc:2#metronome", false);
assert(reused == ids[1]);
std::puts("ok: create / cap refusal / release / slot reuse");
}
static void testReleaseFadesWithLiveConsumer()
{
Mixer m;
const int id = m.createChannel("stems", "plugin", "wc:1#stems", true);
assert(id > 0);
auto chunk = constChunk(RendererBus::kPrimeFrames + 512, 0.5f);
assert(m.pushChannel(id, chunk.data(), RendererBus::kPrimeFrames + 512, 48000.0, 48000.0));
float dl[256], dr[256], sl[256], sr[256];
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
m.pullMixInto(dl, dr, 256, sl, sr);
assert(std::fabs(dl[128] - 0.5f) < 1e-4f); // audible pre-release
// Release with a live consumer: slot drains — the next pull fades to
// silence (start near the running gain, end at exactly zero) then frees.
assert(m.releaseChannel(id, true));
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
const int contributed = m.pullMixInto(dl, dr, 256, sl, sr);
assert(contributed == 1);
assert(std::fabs(dl[0]) > 0.0f); // fade starts from the running gain
assert(std::fabs(dl[255]) < 0.01f); // ...and lands at silence
assert(m.activeChannelCount() == 1);
// Slot is Free again — reusable.
const int reused = m.createChannel("next", "plugin", "wc:1#next", true);
assert(reused == id);
std::puts("ok: release fades to silence under a live consumer, then frees");
}
static void testGainMuteRamps()
{
Mixer m;
const int id = m.createChannel("sfx", "plugin", "wc:1#sfx", true);
auto chunk = constChunk(RendererBus::kPrimeFrames * 8, 1.0f);
m.pushChannel(id, chunk.data(), RendererBus::kPrimeFrames * 8, 48000.0, 48000.0);
float dl[256], dr[256], sl[256], sr[256];
// Settle the ramp at unity.
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
m.pullMixInto(dl, dr, 256, sl, sr);
assert(std::fabs(dl[255] - 1.0f) < 1e-4f);
// Gain step ramps across the block: start near old, end at new.
assert(m.setChannelGain(id, 0.5f));
std::memset(dl, 0, sizeof(dl));
m.pullMixInto(dl, dr, 256, sl, sr);
assert(dl[0] > 0.9f);
assert(std::fabs(dl[255] - 0.5f) < 1e-3f);
// Mute ramps to zero, unmute ramps back.
assert(m.setChannelMute(id, true));
std::memset(dl, 0, sizeof(dl));
m.pullMixInto(dl, dr, 256, sl, sr);
assert(std::fabs(dl[255]) < 1e-3f);
// Native clamp: NaN/huge gains sanitized, never trusted (tier-2 rule).
assert(m.setChannelGain(id, std::numeric_limits<float>::quiet_NaN()));
assert(m.setChannelGain(id, 1e9f));
std::memset(dl, 0, sizeof(dl));
m.setChannelMute(id, false);
m.pullMixInto(dl, dr, 256, sl, sr);
for (int i = 0; i < 256; ++i) assert(std::isfinite(dl[i]) && std::fabs(dl[i]) <= 8.0f);
// Out-of-range ids refused.
assert(!m.setChannelGain(99, 1.0f));
assert(!m.setChannelGain(-1, 1.0f));
std::puts("ok: gain/mute ramps + native clamp");
}
static void testGroupStartGate()
{
Mixer m;
const int a = m.createChannel("stem-a", "plugin", "wc:1#stems", true);
const int b = m.createChannel("stem-b", "plugin", "wc:1#stems", true);
assert(m.setChannelGroup(a, 3));
assert(m.setChannelGroup(b, 3));
// Only member A has audio: the group gate must hold BOTH back.
auto chunk = constChunk(RendererBus::kPrimeFrames * 4, 0.5f);
m.pushChannel(a, chunk.data(), RendererBus::kPrimeFrames * 4, 48000.0, 48000.0);
float dl[128], dr[128], sl[128], sr[128];
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
int contributed = m.pullMixInto(dl, dr, 128, sl, sr);
assert(contributed == 0); // A buffers, gate closed
// B catches up: gate opens, both play in the same block.
m.pushChannel(b, chunk.data(), RendererBus::kPrimeFrames * 4, 48000.0, 48000.0);
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
contributed = m.pullMixInto(dl, dr, 128, sl, sr);
assert(contributed == 2);
// Both contribute 0.5 → sum 1.0 once the ramps settle.
assert(std::fabs(dl[127] - 1.0f) < 1e-3f);
// Ungrouped channels are unaffected by a blocked group.
const int solo = m.createChannel("solo", "plugin", "wc:2#solo", true);
m.pushChannel(solo, chunk.data(), RendererBus::kPrimeFrames * 4, 48000.0, 48000.0);
const int c2 = m.createChannel("stalled", "plugin", "wc:1#stems", true);
m.setChannelGroup(c2, 3); // rejoins group 3 with no audio → gate closes again
std::memset(dl, 0, sizeof(dl));
std::memset(dr, 0, sizeof(dr));
contributed = m.pullMixInto(dl, dr, 128, sl, sr);
assert(contributed == 1); // solo only
std::puts("ok: group start gate (§8.13)");
}
static void testListChannels()
{
Mixer m;
const int id = m.createChannel("stems", "plugin", "wc:1#stems", false);
m.setChannelGain(id, 0.7f);
m.setChannelGroup(id, 2);
Mixer::ChannelInfo infos[Mixer::kMaxChannels];
const int count = m.listChannels(infos);
assert(count == 2);
assert(infos[0].id == 0);
assert(std::strcmp(infos[0].label, "renderer-master") == 0);
assert(std::strcmp(infos[0].kind, "default") == 0);
assert(infos[1].id == id);
assert(std::strcmp(infos[1].label, "stems") == 0);
assert(std::strcmp(infos[1].holder, "wc:1#stems") == 0);
assert(std::fabs(infos[1].gain - 0.7f) < 1e-5f);
assert(infos[1].group == 2);
assert(infos[1].metrics.capacityFrames == RendererBus::kFrames);
// Oversized label/holder are truncated, never overflowed.
std::string longLabel(500, 'x');
const int id2 = m.createChannel(longLabel.c_str(), "plugin", longLabel.c_str(), false);
const int count2 = m.listChannels(infos);
assert(count2 == 3);
assert(std::strlen(infos[2].label) == Mixer::kLabelMax - 1);
assert(std::strlen(infos[2].holder) == Mixer::kHolderMax - 1);
(void) id2;
std::puts("ok: listChannels + bounded strings");
}
int main()
{
testChannelZeroByteCompatible();
testCreateReleaseAndCap();
testReleaseFadesWithLiveConsumer();
testGainMuteRamps();
testGroupStartGate();
testListChannels();
std::puts("mixer_test: all ok");
return 0;
}