// Standalone tests for the live-parameter block itself — no VST3, no REAPER, no framework: // the single fold from the parameter set, the seqlock's coherence under a concurrent writer, // and the ramp's exact termination. The block's effect on a sounding voice is // live_delivery_tests. #include "../src/core/instrument/engine/live_params.h" #include #include #include #include using namespace reasampler; using instrument::engine::LiveParams; using instrument::engine::LiveValues; using instrument::engine::ValueRamp; using instrument::engine::foldLive; using instrument::engine::liveRampStep; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // The block is copied wholesale under the seqlock, so anything that owns memory here would be // a use-after-free waiting for a racing publish. static_assert(std::is_trivially_copyable_v, "the live block must stay plain data"); static void testFoldCarriesEveryContinuousControl() { PlayParams p; p.adsr = AdsrParams{11, 22, 33, 0.44, 55, 2.0, 0.5, 3.0}; p.trigAhd = AhdParams{61, 62, 0.63, 4.0, 0.25}; p.filter.trigEnv = AhdParams{71, 72, 0.73, 5.0, 0.2}; p.filter.enabled = true; p.filter.settings.cutoffNorm = 0.25f; p.filter.settings.resonanceNorm = 0.5f; p.filter.settings.morphNorm = 0.75f; p.filter.settings.driveNorm = 0.125f; p.filter.modAmount = -0.6; p.filter.keyTrack = 1.5; p.filter.env = AdsrParams{1, 2, 3, 0.4, 5}; p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75}; p.pitchEnv.peakSemitones = -3.5; // Distinct from filter.keyTrack below on purpose: the two are different controls and a fold // that crossed them would pass under a shared value. const LiveValues v = foldLive(p, /*keyTrack=*/0.8); CHECK(v.adsr.attackFrames == 11); CHECK(v.adsr.holdFrames == 22); CHECK(v.adsr.decayFrames == 33); CHECK(v.adsr.sustainLevel == 0.44); CHECK(v.adsr.releaseFrames == 55); CHECK(v.adsr.attackCurve == 2.0); CHECK(v.adsr.decayCurve == 0.5); CHECK(v.adsr.releaseCurve == 3.0); CHECK(v.ampAhd.attackFrames == 61); CHECK(v.ampAhd.decayFrames == 62); CHECK(v.ampAhd.holdFraction == 0.63); CHECK(v.ampAhd.attackCurve == 4.0); CHECK(v.ampAhd.decayCurve == 0.25); CHECK(v.filterAhd.attackFrames == 71); CHECK(v.filterAhd.holdFraction == 0.73); CHECK(v.filterAhd.decayCurve == 0.2); CHECK(v.filterSettings.cutoffNorm == 0.25f); CHECK(v.filterSettings.resonanceNorm == 0.5f); CHECK(v.filterSettings.morphNorm == 0.75f); CHECK(v.filterSettings.driveNorm == 0.125f); CHECK(v.filterModAmount == -0.6); CHECK(v.filterKeyTrack == 1.5); CHECK(v.filterEnv.decayFrames == 3); CHECK(v.filterEnv.sustainLevel == 0.4); CHECK(v.pitchEnv.shape.attackFrames == 7); CHECK(v.pitchEnv.shape.decayFrames == 9); CHECK(v.pitchEnv.shape.holdFraction == 0.4); CHECK(v.pitchEnv.shape.attackCurve == 1.5); CHECK(v.pitchEnv.shape.decayCurve == 0.75); CHECK(v.pitchEnv.peakSemitones == -3.5); CHECK(v.keyTrack == 0.8); // Spline-folded on the way in, so the block carries what the voice will actually play. CHECK(!v.splineActive); CHECK(v.lengthFraction == p.trigger.lengthFraction); } // The fold, not the voice, is where a drawn contour pins the Trigger span — so the block a // note-on latches already carries the folded value. static void testADrawnEnvelopePinsTheFoldedTriggerLength() { PlayParams p; p.trigger.lengthFraction = 0.25; p.ampSpline.mode = EnvMode::Spline; const LiveValues v = foldLive(p, kKeyTrackDefault); CHECK(v.splineActive); CHECK(v.lengthFraction == 1.0); } // Poisons the block ONE LEAF FIELD AT A TIME and checks operator== catches every one — the half // that actually catches a forgotten field, since the static_assert above only fires when a // member changes sizeof(LiveValues), which padding can absorb. Covers every leaf of every // nested struct, not just the 14 top-level members, so a member dropped from sameAdsr/sameAhd/ // sameFilterSettings is caught here too, not just a member dropped from operator== itself. static void testEveryFieldOfLiveValuesIsCompared() { using instrument::engine::filter::MorphLaw; const LiveValues base{}; auto poisoned = [&](auto mutate) { LiveValues v = base; mutate(v); return v; }; CHECK(base == base); CHECK(poisoned([](LiveValues& v) { v.filterSettings.cutoffNorm += 0.1f; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterSettings.resonanceNorm += 0.1f; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphNorm += 0.1f; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterSettings.driveNorm += 0.1f; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphLaw = MorphLaw::HighNotchLow; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterModAmount += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterVelAmount += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterKeyTrack += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.holdFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.sustainLevel += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterAhd.holdFraction += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.attackFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.holdFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.decayFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.sustainLevel += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.releaseFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.attackCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.decayCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.adsr.releaseCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.ampAhd.holdFraction += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.enabled = !v.pitchEnv.enabled; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.peakSemitones += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayFrames += 1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.holdFraction += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayCurve += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.playRate += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.pitchOffsetSemitones += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.keyTrack += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.lengthFraction += 0.1; }) != base); CHECK(poisoned([](LiveValues& v) { v.splineActive = !v.splineActive; }) != base); } static void testUnpublishedBlockReadsAsNothing() { LiveParams block; LiveValues out; CHECK(block.read(out) == 0); // never published: the caller must keep its own defaults LiveValues v; v.filterModAmount = 0.5; block.publish(v); const std::uint32_t first = block.read(out); CHECK(first != 0); CHECK(out.filterModAmount == 0.5); // An unchanged block reports the SAME generation, which is how the reader knows there is // nothing to push into the voices. CHECK(block.read(out) == first); block.publish(v); CHECK(block.read(out) != first); } // The torn-read hazard, exercised rather than argued: a writer publishes multi-field edits as // fast as it can while a reader copies the block; every observed block must be one the writer // actually published, never half of one and half of another. static void testConcurrentReaderNeverSeesAHalfAppliedEdit() { LiveParams block; std::atomic stop{false}; std::atomic observed{0}; std::atomic torn{0}; LiveValues seed; seed.adsr = AdsrParams{0, 0, 0, 0.0, 0}; block.publish(seed); std::thread writer([&] { for (std::int64_t i = 1; !stop.load(std::memory_order_relaxed); ++i) { LiveValues v; // One coherent edit: every AHDSR field derives from the same i, so any mixture of // two edits is detectable from the values alone. v.adsr = AdsrParams{i, 2 * i, 3 * i, static_cast(i), 5 * i}; v.filterEnv = AdsrParams{4 * i, 5 * i, 6 * i, static_cast(i), 7 * i}; v.filterModAmount = static_cast(i); block.publish(v); } }); for (int n = 0; n < 200000; ++n) { LiveValues out; if (block.read(out) == 0) continue; // abandoned read: the caller discards it observed.fetch_add(1, std::memory_order_relaxed); const std::int64_t i = out.adsr.attackFrames; const bool coherent = out.adsr.holdFrames == 2 * i && out.adsr.decayFrames == 3 * i && out.adsr.releaseFrames == 5 * i && out.adsr.sustainLevel == static_cast(i) && out.filterEnv.attackFrames == 4 * i && out.filterEnv.holdFrames == 5 * i && out.filterEnv.decayFrames == 6 * i && out.filterEnv.releaseFrames == 7 * i && out.filterModAmount == static_cast(i); if (!coherent) torn.fetch_add(1, std::memory_order_relaxed); } stop.store(true, std::memory_order_relaxed); writer.join(); CHECK(torn.load() == 0); CHECK(observed.load() > 0); // the run proved something only if reads actually landed } static void testRampTerminatesExactlyOnTheTarget() { ValueRamp r; r.set(0.0); r.step = 1.0 / 960.0; // the 20 ms step at 48 kHz r.aim(0.4); int frames = 0; while (r.moving() && frames < 100000) { r.tick(); ++frames; } // Exact equality, not a tolerance: the filter's cutoff-skip fast path compares the value // itself, so an asymptotic smoother would pin it on the always-re-solve path forever. CHECK(r.value == 0.4); CHECK(!r.moving()); CHECK(!r.tick()); // parked: no further movement reported CHECK(frames > 1); // it glided rather than jumping } static void testRampWithNoRateSnaps() { ValueRamp r; r.set(0.2); r.step = liveRampStep(0.0); // rate unknown: never invent one CHECK(r.step == 0.0); r.aim(0.9); CHECK(r.tick()); CHECK(r.value == 0.9); } static void testRampStepIsRateDerived() { CHECK(liveRampStep(48000.0) == 1.0 / (0.020 * 48000.0)); CHECK(liveRampStep(96000.0) == 1.0 / (0.020 * 96000.0)); CHECK(liveRampStep(-1.0) == 0.0); } int main() { testFoldCarriesEveryContinuousControl(); testADrawnEnvelopePinsTheFoldedTriggerLength(); testEveryFieldOfLiveValuesIsCompared(); testUnpublishedBlockReadsAsNothing(); testConcurrentReaderNeverSeesAHalfAppliedEdit(); testRampTerminatesExactlyOnTheTarget(); testRampWithNoRateSnaps(); testRampStepIsRateDerived(); if (g_fail == 0) std::printf("live_params tests passed\n"); return g_fail == 0 ? 0 : 1; }