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
+158
View File
@@ -678,6 +678,149 @@ static void testComponentStateGarbage() {
CHECK(deserializeComponentState(t).map.zones.empty());
}
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
static void testExtractChannelStereo() {
// Interleaved stereo [L0,R0,L1,R1,...]; extract channel 0 -> L's, channel 1 -> R's.
const std::vector<AudioSample> in{0.1f, 0.9f, 0.2f, 0.8f, 0.3f, 0.7f};
const std::vector<AudioSample> l = extractChannel(in, 2, 0);
const std::vector<AudioSample> r = extractChannel(in, 2, 1);
CHECK(l.size() == 3 && approx(l[0], 0.1) && approx(l[1], 0.2) && approx(l[2], 0.3));
CHECK(r.size() == 3 && approx(r[0], 0.9) && approx(r[1], 0.8) && approx(r[2], 0.7));
}
static void testExtractChannelClampsToLast() {
// A mono source asked for channel 1 yields channel 0 (clamp to last) — the dual-mono block.
const std::vector<AudioSample> mono{0.1f, 0.2f, 0.3f};
const std::vector<AudioSample> ch1 = extractChannel(mono, 1, 1);
CHECK(ch1.size() == 3 && approx(ch1[0], 0.1) && approx(ch1[2], 0.3)); // == channel 0
CHECK(extractChannel({}, 2, 0).empty()); // empty in
CHECK(extractChannel({0.1f}, 0, 0).empty()); // zero stride
}
static void testDecodeChannelsMonoModeDownmixes() {
// MONO mode: a stereo source averages to one channel (the existing policy), framesR empty.
const std::vector<AudioSample> stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6)
const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000);
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5));
CHECK(d.framesR.empty()); // mono mode -> single channel
CHECK(d.sampleRate == 48000);
}
static void testDecodeChannelsStereoModeStereoSource() {
// STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct.
const std::vector<AudioSample> stereo{0.1f, 0.9f, 0.2f, 0.8f};
const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100);
CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2));
CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8));
}
static void testDecodeChannelsStereoModeMonoSourceDualMono() {
// STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent).
const std::vector<AudioSample> mono{0.3f, 0.6f, 0.9f};
const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100);
CHECK(d.monoFrames.size() == 3);
CHECK(d.framesR.size() == 3);
for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L
}
// --- S7: buildTier0Keymap stereo threading ------------------------------------
static void testBuildKeymapStereoCarriesSecondChannel() {
const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f});
CHECK(km.samples.size() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8));
}
static void testBuildKeymapMonoWhenNoSecondChannel() {
// No framesR passed -> mono SampleData (byte-identical to the pre-S7 build).
const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{});
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty());
}
static void testBuildKeymapDropsMismatchedSecondChannel() {
// A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays).
const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f});
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1);
}
static void testBuildZonedKeymapCarriesSecondChannel() {
// The zoned build threads each zone's framesR when it length-matches channel 0.
std::vector<ResolvedZone> zones;
ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z);
std::vector<DecodedZonePcm> decoded;
DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f};
decoded.push_back(d);
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2);
CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 &&
approx(km.samples[0].framesR[1], 0.8));
}
// --- S7: component state v4 (channel mode) ------------------------------------
static void testComponentStateV4RoundTripStereo() {
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateV4RoundTripMono() {
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Mono;
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Mono);
}
static void testComponentStateV4DefaultIsMono() {
// A default-constructed state serializes with mono and restores mono (preserves behavior).
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
static void testComponentStateV3LiftsToMono() {
// A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the
// selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload.
std::vector<std::uint8_t> v3;
v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3
const std::string id = "legacy";
v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
v3.insert(v3.end(), id.begin(), id.end());
v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v3);
CHECK(back.selectionId == "legacy");
CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default
CHECK(back.map.zones.empty());
}
static void testComponentStateV1V2LiftToMono() {
// The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
const ComponentState v1 = deserializeComponentState(serializeSelection("old"));
CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
const ComponentState v2 = deserializeComponentState(serializePerformance(m));
CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
}
static void testComponentStateV4TruncatedModeByte() {
// A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
std::vector<std::uint8_t> t{4, 0, 0, 0}; // version 4, nothing after
const ComponentState back = deserializeComponentState(t);
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -726,6 +869,21 @@ int main() {
testComponentStateV1BackCompat();
testComponentStateV2BackCompat();
testComponentStateGarbage();
testExtractChannelStereo();
testExtractChannelClampsToLast();
testDecodeChannelsMonoModeDownmixes();
testDecodeChannelsStereoModeStereoSource();
testDecodeChannelsStereoModeMonoSourceDualMono();
testBuildKeymapStereoCarriesSecondChannel();
testBuildKeymapMonoWhenNoSecondChannel();
testBuildKeymapDropsMismatchedSecondChannel();
testBuildZonedKeymapCarriesSecondChannel();
testComponentStateV4RoundTripStereo();
testComponentStateV4RoundTripMono();
testComponentStateV4DefaultIsMono();
testComponentStateV3LiftsToMono();
testComponentStateV1V2LiftToMono();
testComponentStateV4TruncatedModeByte();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;
+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");