instrument: deliver continuous playback params live to sounding voices via a seqlock block, holding normalized stage position across time edits
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user