From b51f88a574bc6aeb0e403610ad15a9b3db2139df Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 22:27:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(audio):=20engine-owned=20Mixer=20=E2=80=94?= =?UTF-8?q?=20RendererBus=20generalized=20to=20N=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audio/AudioEngine.cpp | 24 ++- src/audio/AudioEngine.h | 47 ++-- src/audio/NodeAddon.cpp | 7 + src/audio/addon/Bindings.h | 7 + src/audio/addon/DeviceBindings.cpp | 112 ++++++++++ src/audio/engine/Mixer.h | 336 +++++++++++++++++++++++++++++ tests/engine_units/CMakeLists.txt | 4 + tests/engine_units/mixer_test.cpp | 236 ++++++++++++++++++++ 8 files changed, 754 insertions(+), 19 deletions(-) create mode 100644 src/audio/engine/Mixer.h create mode 100644 tests/engine_units/mixer_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 4f258ea..4f31abf 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -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& 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; diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index a2db77b..1e28f11 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -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 1–3). 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 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& 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 rendererBusPullScratch; + juce::AudioBuffer mixerChannelScratch; std::atomic outputUnderflowCount{0}; std::atomic inputOverflowCount{0}; diff --git a/src/audio/NodeAddon.cpp b/src/audio/NodeAddon.cpp index 503b8e7..d82dadc 100644 --- a/src/audio/NodeAddon.cpp +++ b/src/audio/NodeAddon.cpp @@ -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)); diff --git a/src/audio/addon/Bindings.h b/src/audio/addon/Bindings.h index d53f754..73f3a44 100644 --- a/src/audio/addon/Bindings.h +++ b/src/audio/addon/Bindings.h @@ -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); diff --git a/src/audio/addon/DeviceBindings.cpp b/src/audio/addon/DeviceBindings.cpp index f68d1d2..267f3a6 100644 --- a/src/audio/addon/DeviceBindings.cpp +++ b/src/audio/addon/DeviceBindings.cpp @@ -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().Utf8Value(); + const std::string kind = info[1].As().Utf8Value(); + const std::string holder = info[2].As().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().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(); + if (ta.TypedArrayType() != napi_float32_array) + return Napi::Boolean::New(env, false); + auto f32 = info[1].As(); + const size_t samples = f32.ElementLength(); + if (samples < 2) + return Napi::Boolean::New(env, false); + const bool ok = liveEngine->mixerPushChannel( + info[0].As().Int32Value(), + f32.Data(), (int) (samples / 2), + info[2].As().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().Int32Value(), + (float) info[1].As().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().Int32Value(), + info[1].As().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().Int32Value(), + info[1].As().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) { diff --git a/src/audio/engine/Mixer.h b/src/audio/engine/Mixer.h new file mode 100644 index 0000000..1990fed --- /dev/null +++ b/src/audio/engine/Mixer.h @@ -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 +#include +#include +#include + +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(); + 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(); + 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 bus; + std::atomic state{(uint32_t) SlotState::Free}; + std::atomic gain{1.0f}; + std::atomic mute{false}; + std::atomic group{-1}; + std::atomic 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 channelCount{0}; +}; + +} // namespace slopsmith diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index b838a8f..229e941 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -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) diff --git a/tests/engine_units/mixer_test.cpp b/tests/engine_units/mixer_test.cpp new file mode 100644 index 0000000..120e438 --- /dev/null +++ b/tests/engine_units/mixer_test.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +using slopsmith::Mixer; +using slopsmith::RendererBus; + +static std::vector constChunk(int frames, float value) +{ + std::vector 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::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; +}