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;