S7: stereo channel mode — core channel dimension, v4 mode state, VST3 bus negotiation, editor toggle

Per-instance mono|stereo (default mono, byte-identical). Stereo grows SampleData a 2nd
channel + a per-channel VoiceEngine render; decodeChannels applies the cross-mode policy;
setBusArrangements pins the mode's arrangement and restartComponent(kIoChanged) re-negotiates.
This commit is contained in:
2026-07-26 21:26:21 -04:00
parent ee82b50fb7
commit 4a3551b3b9
11 changed files with 727 additions and 85 deletions
+119
View File
@@ -562,6 +562,118 @@ static void testPolyphonyMixesAdditively() {
CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum
}
// ---------------------------------------------------------------------------
// 7. Stereo channel dimension (S7).
// ---------------------------------------------------------------------------
// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render
// must keep them distinct; a mono render (channel 0 only) sees L.
static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) {
SampleData s;
s.frames.assign(frames, l);
s.framesR.assign(frames, r);
s.rootNote = rootNote;
return s;
}
static void testChannelCount() {
// Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2.
CHECK(dcSample(10, 60).channelCount() == 1);
CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2);
// A mismatched framesR length is treated as mono (a bad pair never half-plays).
SampleData bad = dcSample(10, 60);
bad.framesR.assign(5, 0.5f); // wrong length
CHECK(bad.channelCount() == 1);
}
static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8);
for (std::size_t i = 0; i < 8; ++i) {
CHECK(approx(left[i], 1.0, 1e-4)); // channel 0
CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L
}
}
static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8);
for (std::size_t i = 0; i < 8; ++i) {
CHECK(approx(left[i], 1.0, 1e-4));
CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0
}
}
static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS, flatAdsr());
engS.noteOn(60, 127);
std::vector<AudioSample> mono;
engS.render(mono, 8); // the mono overload
for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R
}
static void testStereoRenderAdvancesLikeMonoRepitch() {
// The stereo path must advance the read head by the SAME per-frame ratio as the mono path,
// so repitch is identical. Play a stereo sine (both channels the same signal) an octave up
// and confirm the observed period halves — the mono repitch assertion, on the stereo path.
const std::size_t frames = 8000;
const double cycles = 20.0;
const double nativePeriod = static_cast<double>(frames) / cycles; // 400
SampleData s;
s.frames.resize(frames);
s.framesR.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
const float v = static_cast<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
s.frames[i] = v;
s.framesR[i] = v;
}
s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km, flatAdsr());
eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
eng.render(left.data(), right.data(), frames / 2);
CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0));
CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically
}
static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km, flatAdsr());
eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
eng.render(left.data(), right.data(), 1);
CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5
CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5
}
static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f);
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
eng.render(buf.data(), nullptr, 4); // null right -> no-op
for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
@@ -582,6 +694,13 @@ int main() {
testAbsentLoopGoesSilent();
testVelocityToVolume();
testPolyphonyMixesAdditively();
testChannelCount();
testStereoRenderKeepsChannelsDistinct();
testMonoSamplePlaysDualMonoInStereo();
testMonoRenderUnchangedByStereoData();
testStereoRenderAdvancesLikeMonoRepitch();
testStereoRenderSumsVoicesPerChannel();
testStereoRenderNullBufferIsNoOp();
if (g_fail == 0) {
std::printf("all sampler_core tests passed\n");