Files
reasampler/tests/test_live_params.cpp
T
daniel 9b5393098b Close pass four: undetented host curve read, LiveValues guard, ordering assert, static-lane fix, docs
Points toPlain's exponent arm at the undetented curve map so host reads
match the editor; adds a sizeof guard plus field-poison test for
LiveValues::operator==; skips the model write when an automation value
hasn't moved; corrects five stale doc citations.
2026-08-02 18:22:21 -04:00

262 lines
12 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;
// 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<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();
testADrawnEnvelopePinsTheFoldedTriggerLength();
testEveryFieldOfLiveValuesIsCompared();
testUnpublishedBlockReadsAsNothing();
testConcurrentReaderNeverSeesAHalfAppliedEdit();
testRampTerminatesExactlyOnTheTarget();
testRampWithNoRateSnaps();
testRampStepIsRateDerived();
if (g_fail == 0) std::printf("live_params tests passed\n");
return g_fail == 0 ? 0 : 1;
}