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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user