// Standalone tests for LIVE CONTOUR DELIVERY — a drawn EG edited while notes sound — no VST3, // no REAPER, no framework. Asserts that each of the three contours (amp, pitch, filter) moves // the note already sounding and the next one struck, that the swap is absorbed rather than // stepped, that what start() latched (a Staged EG) stays latched, that an unchanged publish is // byte-identical, that two engines sharing one block both follow (the drain slot), and that the // whole audio-side path allocates nothing. This executable replaces the global allocator to // prove the last one, which is why these tests are not in live_delivery_tests. #include "../src/core/instrument/engine/live_contours.h" #include "../src/core/instrument/engine/voice_engine.h" #include #include #include #include #include #include #include using namespace reasampler; using instrument::engine::ContourPoints; using instrument::engine::CurveDomain; using instrument::engine::LiveContourBlock; using instrument::engine::contourPoints; using instrument::engine::foldContours; // --- Allocation counter ----------------------------------------------------------------- // Counts every global allocation while armed. Replacing the global operator new is the only way // to observe allocations made inside sampler_core without instrumenting it. static std::atomic g_allocations{0}; static std::atomic g_countAllocations{false}; static void* countedAlloc(std::size_t n) { if (g_countAllocations.load(std::memory_order_relaxed)) { g_allocations.fetch_add(1, std::memory_order_relaxed); } if (void* p = std::malloc(n ? n : 1)) return p; throw std::bad_alloc(); } void* operator new(std::size_t n) { return countedAlloc(n); } void* operator new[](std::size_t n) { return countedAlloc(n); } void operator delete(void* p) noexcept { std::free(p); } void operator delete[](void* p) noexcept { std::free(p); } void operator delete(void* p, std::size_t) noexcept { std::free(p); } void operator delete[](void* p, std::size_t) noexcept { std::free(p); } // ------------------------------------------------------------------------------------------ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) constexpr double kPi = 3.14159265358979323846; constexpr int kRate = 48000; constexpr std::size_t kBlock = 256; constexpr int kRoot = 60; // Every frame 1.0, Trigger (a drawn EG is a Trigger fact — resolvePlay's job on the real path), // played at the root with a flat velocity curve: an output frame IS the amplitude. static SampleData dcSample(std::size_t frames) { SampleData s; s.frames.assign(frames, 1.0f); s.sampleRate = kRate; s.rootNote = kRoot; s.play.playMode = PlayMode::Trigger; return s; } static VelocityCurve flatAt(double y, CurveDomain domain) { return VelocityCurve::fromPoints({{0.0, y}, {127.0, y}}, domain); } // Three points rather than two, so a publish that changes the point COUNT is what lands. static VelocityCurve flatAt3(double y, CurveDomain domain) { return VelocityCurve::fromPoints({{0.0, y}, {63.0, y}, {127.0, y}}, domain); } static double maxAbsDelta(const std::vector& v, std::size_t from, std::size_t to) { double worst = 0.0; for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { worst = (std::max)(worst, std::fabs(static_cast(v[i]) - v[i - 1])); } return worst; } static double peakOf(const std::vector& v, std::size_t from, std::size_t to) { double peak = 0.0; for (std::size_t i = from; i < to && i < v.size(); ++i) { peak = (std::max)(peak, std::fabs(static_cast(v[i]))); } return peak; } // One held note through a one-voice engine observing `block`, with `edited`'s contours // published at the top of block `swapBlock` (-1: never). The initial publish is `sample.play`'s // own contours, the state a reload leaves. static std::vector renderHeld(SampleData& sample, LiveContourBlock& block, const PlayParams* edited, int swapBlock, int blocks) { sample.liveContours = █ block.publish(foldContours(sample.play)); VoiceEngine engine(1, sample); engine.noteOn(kRoot, 127); std::vector out; for (int b = 0; b < blocks; ++b) { if (edited && b == swapBlock) block.publish(foldContours(*edited)); engine.render(out, kBlock); } return out; } // --- The fold ------------------------------------------------------------------------------ static void testTheFoldCarriesEveryPointAndItsHardFlag() { std::vector pts = {{0.0, 0.1}, {40.0, 0.9}, {90.0, 0.2}, {127.0, 0.7}}; VelocityCurve c = VelocityCurve::fromPoints(pts, CurveDomain::Unipolar); c.setHard(2, true); const ContourPoints folded = contourPoints(c); CHECK(folded.count == 4); for (std::size_t i = 0; i < 4; ++i) { CHECK(folded.points[i].velocity == c.points()[i].velocity); CHECK(folded.points[i].value == c.points()[i].value); CHECK(folded.points[i].hard == c.points()[i].hard); } CHECK(folded.points[2].hard); PlayParams p; p.ampSpline.contour = flatAt(0.3, CurveDomain::Unipolar); p.pitchSpline.contour = c; p.filterSpline.contour = flatAt3(0.6, CurveDomain::Unipolar); const auto all = foldContours(p); CHECK(all.amp.count == 2 && all.amp.points[1].value == 0.3); CHECK(all.pitch.count == 4 && all.pitch.points[1].value == c.points()[1].value); CHECK(all.filter.count == 3 && all.filter.points[2].value == 0.6); } // --- Amp ----------------------------------------------------------------------------------- static void testAHeldNoteFollowsAnAmpContourEditWithoutAStep() { SampleData s = dcSample(200000); s.play.ampSpline.mode = EnvMode::Spline; s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar); PlayParams edited = s.play; edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar); SampleData base = s; LiveContourBlock blockA, blockB; const std::vector moved = renderHeld(s, blockA, &edited, 4, 12); const std::vector still = renderHeld(base, blockB, nullptr, -1, 12); const std::size_t swapAt = 4 * kBlock; CHECK(moved[swapAt - 1] == 1.0f); // Absorbed: the first frame read through the new contour reproduces the old level exactly... CHECK(moved[swapAt] == 1.0f); // ...and glides from there. An unabsorbed swap would step by 0.75 in one frame. CHECK(maxAbsDelta(moved, swapAt - 1, swapAt + 64) < 0.05); CHECK(moved[swapAt + 32] < 0.9f); // The held note lands on the edited contour, where the unedited run stays on the old one. CHECK(std::fabs(moved.back() - 0.25) < 1e-6); CHECK(still.back() == 1.0f); } // The processor marshals note-ons BEFORE the block renders, so a publish and a note-on landing // in one block reach the engine through startVoice first. The voice already sounding must be // rebound there too: left on the buffer the read just displaced, it would never see this edit. static void testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne() { SampleData s = dcSample(200000); s.play.ampSpline.mode = EnvMode::Spline; s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar); LiveContourBlock block; s.liveContours = █ block.publish(foldContours(s.play)); VoiceEngine engine(2, s); engine.noteOn(kRoot, 127); std::vector out; for (int b = 0; b < 4; ++b) engine.render(out, kBlock); PlayParams edited = s.play; edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar); block.publish(foldContours(edited)); engine.noteOn(kRoot, 127); // the second voice, struck in the swap block for (int b = 0; b < 8; ++b) engine.render(out, kBlock); const std::size_t swapAt = 4 * kBlock; // The sounding voice's absorbed 1.0 plus the fresh voice's snapped 0.25 — the fresh one // takes the edit outright even though the snapshot still carries the old contour. CHECK(std::fabs(out[swapAt] - 1.25) < 1e-6); // Both land on the edit: 0.25 + 0.25. A sounding voice that missed it would leave 1.25. CHECK(std::fabs(out.back() - 0.5) < 1e-6); } static void testANoteStruckAfterAnEditPlaysItFromItsFirstFrame() { SampleData s = dcSample(200000); s.play.ampSpline.mode = EnvMode::Spline; s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar); LiveContourBlock block; s.liveContours = █ block.publish(foldContours(s.play)); VoiceEngine engine(1, s); PlayParams edited = s.play; edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar); block.publish(foldContours(edited)); engine.noteOn(kRoot, 127); std::vector out; engine.render(out, kBlock); CHECK(out[0] == 0.25f); // The snapshot really does still say 1.0: with no block the same engine plays it. SampleData bare = s; bare.liveContours = nullptr; VoiceEngine bareEngine(1, bare); bareEngine.noteOn(kRoot, 127); std::vector bareOut; bareEngine.render(bareOut, kBlock); CHECK(bareOut[0] == 1.0f); } // --- Pitch --------------------------------------------------------------------------------- // A ramp source under Varispeed: an output frame is the read head, so the frame-to-frame step // IS the pitch ratio. static void testAHeldNoteFollowsAPitchContourEdit() { SampleData s; s.frames.resize(1u << 15); for (std::size_t i = 0; i < s.frames.size(); ++i) s.frames[i] = static_cast(i); s.sampleRate = kRate; s.rootNote = kRoot; s.play.playMode = PlayMode::Trigger; s.play.pitchEnv.enabled = true; s.play.pitchEnv.peakSemitones = 12.0; s.play.pitchSpline.mode = EnvMode::Spline; s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar); PlayParams edited = s.play; edited.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); // +1 x 12 st = an octave SampleData base = s; LiveContourBlock blockA, blockB; const std::vector moved = renderHeld(s, blockA, &edited, 4, 12); const std::vector still = renderHeld(base, blockB, nullptr, -1, 12); const std::size_t swapAt = 4 * kBlock; const std::size_t n = moved.size(); CHECK(std::fabs((moved[swapAt - 1] - moved[swapAt - 2]) - 1.0) < 1e-3); CHECK(std::fabs((moved[n - 1] - moved[n - 2]) - 2.0) < 1e-3); CHECK(std::fabs((still[n - 1] - still[n - 2]) - 1.0) < 1e-3); // Absorbed here too: the first frame read through the new contour still advances at the old // ratio, where an unabsorbed swap would already step by 2. CHECK(std::fabs((moved[swapAt + 1] - moved[swapAt]) - 1.0) < 1e-3); } // --- Filter -------------------------------------------------------------------------------- static void testAHeldNoteFollowsAFilterContourEdit() { SampleData s; s.frames.resize(100000); for (std::size_t i = 0; i < s.frames.size(); ++i) { s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / 8.0)); } s.sampleRate = kRate; s.rootNote = kRoot; s.play.playMode = PlayMode::Trigger; s.play.filter.enabled = true; s.play.filter.settings.cutoffNorm = 0.1f; s.play.filter.settings.morphNorm = 1.0f; // low-pass s.play.filter.modAmount = 0.8; s.play.filterSpline.mode = EnvMode::Spline; s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar); PlayParams edited = s.play; edited.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); // corner 0.1 -> 0.9 SampleData base = s; LiveContourBlock blockA, blockB; const std::vector moved = renderHeld(s, blockA, &edited, 4, 16); const std::vector still = renderHeld(base, blockB, nullptr, -1, 16); const std::size_t tail = moved.size() - 1024; const double openedPeak = peakOf(moved, tail, moved.size()); const double closedPeak = peakOf(still, tail, still.size()); // A 6 kHz tone through the corner the edit opened, against the unedited note's, which stays // filtered away (measured ~0.18 vs ~0.0002 — the margins below are loose on purpose). CHECK(openedPeak > 0.1); CHECK(openedPeak > 10.0 * closedPeak); } // --- What stays latched, and what must not move -------------------------------------------- // Staged|Spline is a note-on fact: an EG that start() left Staged ignores any contour publish. static void testAStagedEnvelopeIgnoresContourPublishes() { SampleData s = dcSample(200000); // A staged shape that is not flat from frame 0, so a leak would show. s.play.trigAhd.decayFrames = 20000; s.play.trigAhd.holdFraction = 0.0; PlayParams hostile = s.play; hostile.ampSpline.contour = flatAt3(0.0, CurveDomain::Unipolar); hostile.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); hostile.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); SampleData base = s; LiveContourBlock blockA, blockB; const std::vector published = renderHeld(s, blockA, &hostile, 3, 10); const std::vector none = renderHeld(base, blockB, nullptr, -1, 10); CHECK(published == none); CHECK(none[3 * kBlock + 100] < 1.0f); // the staged decay is really running } // Republishing the contour a voice is already reading rebinds it mid-read; the read must not // move by a bit. A curved, multi-segment contour with a hard point so the re-located segment // and its tangents are genuinely exercised. static void testRepublishingAnUnchangedContourIsByteIdentical() { SampleData s = dcSample(20000); s.play.ampSpline.mode = EnvMode::Spline; VelocityCurve c = VelocityCurve::fromPoints( {{0.0, 0.0}, {20.0, 1.0}, {60.0, 0.3}, {100.0, 0.9}, {127.0, 0.0}}, CurveDomain::Unipolar); c.setHard(2, true); s.play.ampSpline.contour = c; const PlayParams same = s.play; SampleData base = s; LiveContourBlock blockA, blockB; const std::vector republished = renderHeld(s, blockA, &same, 20, 60); const std::vector none = renderHeld(base, blockB, nullptr, -1, 60); CHECK(republished == none); CHECK(peakOf(none, 0, none.size()) > 0.9); // the contour really is being read } // The drain slot: a displaced snapshot and the live one both observe the instance's ONE block, // so a note ringing out of either follows the edit. static void testTwoEnginesSharingOneBlockBothFollow() { SampleData a = dcSample(200000); a.play.ampSpline.mode = EnvMode::Spline; a.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar); SampleData b = a; LiveContourBlock block; a.liveContours = █ b.liveContours = █ block.publish(foldContours(a.play)); VoiceEngine live(1, a), drain(1, b); live.noteOn(kRoot, 127); drain.noteOn(kRoot, 127); std::vector outLive, outDrain; for (int i = 0; i < 2; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); } PlayParams edited = a.play; edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar); block.publish(foldContours(edited)); for (int i = 0; i < 8; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); } CHECK(std::fabs(outLive.back() - 0.25) < 1e-6); CHECK(std::fabs(outDrain.back() - 0.25) < 1e-6); } // --- Allocation ---------------------------------------------------------------------------- // The counter itself must be live, or the zero below would prove nothing. static void testTheAllocationCounterSeesAnAllocation() { g_allocations.store(0); g_countAllocations.store(true); std::vector* v = new std::vector(16); g_countAllocations.store(false); delete v; CHECK(g_allocations.load() >= 2); // the vector object and its buffer } // Every audio-side step of a contour swap — the block-boundary read, the rebind of every // sounding voice on all three EGs, a note struck in the swap block, and the renders around them // — runs with the counter armed. Only the UI thread's publish is outside it (it allocates // nothing either, but it is not the audio side). static void testTheAudioSideOfAContourSwapAllocatesNothing() { SampleData s = dcSample(200000); s.play.ampSpline.mode = EnvMode::Spline; s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar); s.play.pitchEnv.enabled = true; s.play.pitchEnv.peakSemitones = 0.0; // bound, so rebound, but transposing nothing s.play.pitchSpline.mode = EnvMode::Spline; s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar); s.play.filter.enabled = true; s.play.filter.settings.cutoffNorm = 1.0f; s.play.filter.modAmount = 0.0; s.play.filterSpline.mode = EnvMode::Spline; s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar); PlayParams edited = s.play; edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar); edited.pitchSpline.contour = flatAt3(0.0, CurveDomain::Bipolar); edited.filterSpline.contour = flatAt3(0.5, CurveDomain::Unipolar); LiveContourBlock block; s.liveContours = █ block.publish(foldContours(s.play)); VoiceEngine engine(4, s); std::vector buf(kBlock, 0.0f); const auto renderBlock = [&] { std::fill(buf.begin(), buf.end(), 0.0f); engine.render(buf.data(), kBlock); }; g_allocations.store(0); g_countAllocations.store(true); engine.noteOn(kRoot, 127); for (int b = 0; b < 4; ++b) renderBlock(); g_countAllocations.store(false); block.publish(foldContours(edited)); // UI thread g_countAllocations.store(true); engine.noteOn(kRoot, 127); // startVoice's rebind + snap renderBlock(); // the block boundary's read finds nothing newer: already taken g_countAllocations.store(false); const double afterEdit = buf[kBlock - 1]; block.publish(foldContours(s.play)); // and back, so the render path does the read itself g_countAllocations.store(true); for (int b = 0; b < 8; ++b) renderBlock(); g_countAllocations.store(false); CHECK(g_allocations.load() == 0); // Not vacuous: both swaps really landed inside the counted window, through a wide-open // low-pass whose DC gain is unity. Two voices on the edited 0.25 contour by the end of the // swap block (2.0 had it not landed), then both back on 1.0 through the render path's read. CHECK(std::fabs(afterEdit - 0.5) < 1e-3); CHECK(std::fabs(buf[kBlock - 1] - 2.0) < 1e-3); } int main() { testTheFoldCarriesEveryPointAndItsHardFlag(); testAHeldNoteFollowsAnAmpContourEditWithoutAStep(); testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne(); testANoteStruckAfterAnEditPlaysItFromItsFirstFrame(); testAHeldNoteFollowsAPitchContourEdit(); testAHeldNoteFollowsAFilterContourEdit(); testAStagedEnvelopeIgnoresContourPublishes(); testRepublishingAnUnchangedContourIsByteIdentical(); testTwoEnginesSharingOneBlockBothFollow(); testTheAllocationCounterSeesAnAllocation(); testTheAudioSideOfAContourSwapAllocatesNothing(); if (g_fail == 0) std::printf("live_contours tests passed\n"); return g_fail == 0 ? 0 : 1; }