diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 57a2970..7763915 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -941,6 +941,44 @@ static void testComponentStateV4TruncatedModeByte() { CHECK(back.selectionId.empty() && back.map.zones.empty()); } +static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { + // The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying + // channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it + // losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in + // the self-versioned zones payload — the two tracks are orthogonal, so both survive one + // serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip + // covers overrides at the default mono mode; this asserts them TOGETHER.) + ComponentState s; + s.selectionId = "pick"; + s.channelMode = ChannelMode::Stereo; + PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36); + SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000; + z0.loopOverride = lp0; + z0.startPoint = 128; + PerformanceZone z1 = zone("z1", 49, 127); // second zone: no overrides (mixed payload) + s.map.zones.push_back(z0); + s.map.zones.push_back(z1); + + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives + CHECK(back.selectionId == "pick"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() && + *back.map.zones[0].rootOverride == 36); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() && + back.map.zones[0].loopOverride->hasLoop && + back.map.zones[0].loopOverride->start == 500 && + back.map.zones[0].loopOverride->end == 9000); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() && + *back.map.zones[0].startPoint == 128); + // The override-free second zone stays override-free (the payload framing per zone is intact). + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + !back.map.zones[1].loopOverride.has_value() && + !back.map.zones[1].startPoint.has_value()); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -1010,6 +1048,7 @@ int main() { testComponentStateV3LiftsToMono(); testComponentStateV1V2LiftToMono(); testComponentStateV4TruncatedModeByte(); + testComponentStateV4StereoWithZoneOverridesRoundTrip(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 9080bd8..8d25484 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -783,6 +783,52 @@ static void testStereoRenderNullBufferIsNoOp() { for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched } +static void testStereoStartFrameLoopShareOneReadHead() { + // S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH + // channels from the SAME single read head — one offset, one loop wrap, applied to L and R + // identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset + // apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave + // L and R independent heads, the constant L->R offset would break at the start jump or the + // loop seam. + SampleData s; + s.frames.resize(40); + s.framesR.resize(40); + for (int i = 0; i < 40; ++i) { + s.frames[i] = static_cast(i) * 0.01f; // L: 0.00 .. 0.39 + s.framesR[i] = static_cast(i) * 0.01f + 0.5f; // R: L + 0.5, everywhere + } + s.rootNote = 60; + s.startFrame = 10; // begin BOTH channels at frame 10 + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 30; // loop [20,30): frames 20..29 + CHECK(s.channelCount() == 2); + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity, flat gain + + std::vector left(200, 0.f), right(200, 0.f); + eng.render(left.data(), right.data(), 200); + + // First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset. + CHECK(approx(left[0], 0.10, 1e-4)); + CHECK(approx(right[0], 0.60, 1e-4)); + // The loop sustains the voice indefinitely. + CHECK(eng.activeVoiceCount() == 1); + // At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index + // (one read head) through the start jump and every loop wrap. A per-channel head drift would + // break this invariant at the seam. + for (std::size_t i = 0; i < left.size(); ++i) { + CHECK(approx(right[i] - left[i], 0.5, 1e-4)); + } + // Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio + // frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the + // loop region on both channels, never running off the sample end. + for (std::size_t i = 15; i < left.size(); ++i) { + CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4); + } +} + int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -815,6 +861,7 @@ int main() { testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderSumsVoicesPerChannel(); testStereoRenderNullBufferIsNoOp(); + testStereoStartFrameLoopShareOneReadHead(); if (g_fail == 0) { std::printf("all sampler_core tests passed\n");