Files
reasampler/tests/test_live_params.cpp
T
daniel 13e8c5c4d9 instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes
Trigger's fade pair folds into the AHD (and goes live); the release anchors right;
Preserve rings its synthetic tail out instead of cutting it. Payload v10.
2026-07-31 08:37:57 -04:00

181 lines
6.9 KiB
C++

// 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 <atomic>
#include <cstdio>
#include <thread>
#include <type_traits>
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<LiveValues>, "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<bool> stop{false};
std::atomic<int> observed{0};
std::atomic<int> 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<double>(i), 5 * i};
v.filterEnv = AdsrParams{4 * i, 5 * i, 6 * i, static_cast<double>(i), 7 * i};
v.filterModAmount = static_cast<double>(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<double>(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<double>(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;
}