166 lines
6.2 KiB
C++
166 lines
6.2 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};
|
|
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.attackFrames = 7;
|
|
p.pitchEnv.decayFrames = 9;
|
|
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.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.pitchEnvAttackFrames == 7);
|
|
CHECK(v.pitchEnvDecayFrames == 9);
|
|
CHECK(v.pitchEnvPeakSemitones == -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;
|
|
}
|