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