// 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; const LiveValues v = foldLive(p); 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); } 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(); testUnpublishedBlockReadsAsNothing(); testConcurrentReaderNeverSeesAHalfAppliedEdit(); testRampTerminatesExactlyOnTheTarget(); testRampWithNoRateSnaps(); testRampStepIsRateDerived(); if (g_fail == 0) std::printf("live_params tests passed\n"); return g_fail == 0 ? 0 : 1; }