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:
@@ -186,7 +186,40 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
||||
}
|
||||
|
||||
static void testLiveRoutingCoversEveryContinuousPlaybackControl() {
|
||||
// The live set: the six filter tone/modulation knobs, plus every stage time and stage
|
||||
// level on all three envelopes.
|
||||
const DeckParam live[] = {
|
||||
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
|
||||
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
|
||||
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
|
||||
DeckParam::kRelease,
|
||||
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
|
||||
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
|
||||
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth,
|
||||
};
|
||||
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
|
||||
|
||||
// Everything else reloads or rebuilds. kFilterVel and kKeyTrack are the two that look
|
||||
// continuous but feed values a voice latches at note-on (the velocity-curve result and
|
||||
// the pitch ratio) — making them live would re-gain or retune a note already struck.
|
||||
const DeckParam reloads[] = {
|
||||
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
||||
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
|
||||
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn,
|
||||
DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
|
||||
};
|
||||
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
|
||||
|
||||
// Every id in the space is classified by one of the two lists above, so a control added
|
||||
// later cannot slip through unclassified.
|
||||
const std::size_t classified = (sizeof(live) + sizeof(reloads)) / sizeof(DeckParam);
|
||||
CHECK(classified == static_cast<std::size_t>(DeckParam::kCount));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLiveRoutingCoversEveryContinuousPlaybackControl();
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
// Standalone tests for LIVE PARAMETER DELIVERY into a sounding voice — no VST3, no REAPER, no
|
||||
// framework. The block's own publication contract is live_params_tests; this file asserts what
|
||||
// reaches the audio: the mid-stage rule holds normalized position, a level move glides, a
|
||||
// filter knob moves the note that is already playing, two snapshots sharing one block behave
|
||||
// identically (the drain slot), what stays latched at note-on stays latched, and an unmoved
|
||||
// block renders byte-identically to the engine with no block at all.
|
||||
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using instrument::engine::LiveParams;
|
||||
using instrument::engine::LiveValues;
|
||||
using instrument::engine::foldLive;
|
||||
namespace flt = reasampler::instrument::engine::filter;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
static SampleData periodicSine(std::size_t frames, double period) {
|
||||
SampleData s;
|
||||
s.frames.resize(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
|
||||
}
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
// Amp held wide open so a rendered frame is the (filtered) source, undisturbed by the
|
||||
// envelope under test elsewhere in this file.
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
return s;
|
||||
}
|
||||
|
||||
static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
|
||||
double worst = 0.0;
|
||||
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
|
||||
const double d = std::fabs(static_cast<double>(v[i]) - static_cast<double>(v[i - 1]));
|
||||
if (d > worst) worst = d;
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// --- The mid-stage rule (candidate iv): hold normalized stage position ------------------
|
||||
|
||||
static void testStageDurationChangeHoldsPhase() {
|
||||
AdsrParams p;
|
||||
p.attackFrames = 1000;
|
||||
p.sustainLevel = 1.0;
|
||||
|
||||
AdsrEnvelope unedited, edited;
|
||||
unedited.configure(p);
|
||||
edited.configure(p);
|
||||
unedited.noteOn();
|
||||
edited.noteOn();
|
||||
for (int i = 0; i < 500; ++i) { unedited.tick(); edited.tick(); }
|
||||
|
||||
AdsrParams longer = p;
|
||||
longer.attackFrames = 2000; // doubled while the voice sits halfway up the attack
|
||||
edited.applyLive(longer);
|
||||
|
||||
// Continuity: the very next frame is UNCHANGED by the edit. Exact, not approximate —
|
||||
// phi is held, and the level is a pure function of phi.
|
||||
const double a = unedited.tick();
|
||||
const double b = edited.tick();
|
||||
CHECK(a == b);
|
||||
CHECK(std::fabs(b - 0.5) < 1e-12); // and it is genuinely mid-attack, not a degenerate 0/1
|
||||
|
||||
// The remainder takes its share of the NEW duration: half of 2000 frames left to run.
|
||||
for (int i = 0; i < 998; ++i) edited.tick();
|
||||
CHECK(edited.stage() == AdsrEnvelope::Stage::Attack);
|
||||
edited.tick();
|
||||
CHECK(edited.stage() != AdsrEnvelope::Stage::Attack);
|
||||
}
|
||||
|
||||
static void testShortenedStageStillLandsContinuously() {
|
||||
AdsrParams p;
|
||||
p.attackFrames = 1000;
|
||||
p.sustainLevel = 1.0;
|
||||
AdsrEnvelope env;
|
||||
env.configure(p);
|
||||
env.noteOn();
|
||||
double last = 0.0;
|
||||
for (int i = 0; i < 800; ++i) last = env.tick();
|
||||
|
||||
AdsrParams shorter = p;
|
||||
shorter.attackFrames = 100; // now SHORTER than the frames already elapsed
|
||||
env.applyLive(shorter);
|
||||
const double next = env.tick();
|
||||
// Recomputing from absolute elapsed (800/100) would clamp to 1.0 — a step from ~0.8. The
|
||||
// phi rule keeps the level where it was and finishes the remaining 20% over 20 frames.
|
||||
CHECK(std::fabs(next - last) < 2e-3);
|
||||
for (int i = 0; i < 19; ++i) env.tick();
|
||||
CHECK(env.stage() != AdsrEnvelope::Stage::Attack);
|
||||
}
|
||||
|
||||
static void testSustainLevelChangeGlides() {
|
||||
AdsrParams p;
|
||||
p.sustainLevel = 1.0;
|
||||
p.releaseFrames = 100000;
|
||||
AdsrEnvelope env;
|
||||
env.configure(p);
|
||||
env.noteOn();
|
||||
for (int i = 0; i < 50; ++i) env.tick();
|
||||
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
|
||||
|
||||
AdsrParams quieter = p;
|
||||
quieter.sustainLevel = 0.2;
|
||||
env.applyLive(quieter);
|
||||
|
||||
double prev = 1.0;
|
||||
double worstStep = 0.0;
|
||||
double v = 0.0;
|
||||
for (int i = 0; i < 600; ++i) {
|
||||
v = env.tick();
|
||||
if (i == 0) CHECK(v == 1.0); // the first frame reproduces the pre-change level exactly
|
||||
const double step = std::fabs(v - prev);
|
||||
if (step > worstStep) worstStep = step;
|
||||
prev = v;
|
||||
}
|
||||
// A raw parameter swap would step 0.8 in one frame; the glide's largest single step is a
|
||||
// small fraction of that, and it terminates exactly on the new level.
|
||||
CHECK(worstStep < 0.05);
|
||||
CHECK(v == 0.2);
|
||||
}
|
||||
|
||||
static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
|
||||
PitchEnvParams p;
|
||||
p.enabled = true;
|
||||
p.attackFrames = 0;
|
||||
p.decayFrames = 1000;
|
||||
p.peakSemitones = 12.0;
|
||||
PitchEnvelope a, b;
|
||||
a.configure(p);
|
||||
b.configure(p);
|
||||
a.noteOn();
|
||||
b.noteOn();
|
||||
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
|
||||
|
||||
b.applyLive(0, 2000, 12.0); // decay doubled mid-decay
|
||||
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
|
||||
|
||||
// A depth move is a level step, so it glides rather than jumping: the first frame after
|
||||
// the edit is exactly what the unedited peer emits.
|
||||
PitchEnvelope c, d;
|
||||
c.configure(p);
|
||||
d.configure(p);
|
||||
c.noteOn();
|
||||
d.noteOn();
|
||||
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
|
||||
c.applyLive(0, 1000, 0.0); // depth to zero mid-decay
|
||||
CHECK(c.tick() == d.tick());
|
||||
// ...and it does eventually reach the new depth rather than staying put.
|
||||
for (int i = 0; i < 400; ++i) c.tick();
|
||||
CHECK(c.tick() == 0.0);
|
||||
}
|
||||
|
||||
// --- The filter DSP's glide property, finally exercised ---------------------------------
|
||||
|
||||
static void testCutoffMoveAcrossPrepareDoesNotStep() {
|
||||
flt::FilterSettings s;
|
||||
s.cutoffNorm = 0.8f;
|
||||
s.resonanceNorm = 1.0f; // maximum Q: the worst case for a coefficient step
|
||||
s.morphNorm = 1.0f;
|
||||
|
||||
flt::VoiceFilter glide, cut;
|
||||
glide.prepare(s, kRate);
|
||||
cut.prepare(s, kRate);
|
||||
|
||||
std::vector<AudioSample> a, b;
|
||||
const int boundary = 2000;
|
||||
for (int i = 0; i < 4000; ++i) {
|
||||
if (i == boundary) {
|
||||
flt::FilterSettings moved = s;
|
||||
moved.cutoffNorm = 0.3f;
|
||||
glide.prepare(moved, kRate); // state PRESERVED — the documented glide property
|
||||
cut.prepare(moved, kRate);
|
||||
cut.reset(); // the control: state cleared, as at note-on
|
||||
}
|
||||
const float x = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 48.0));
|
||||
a.push_back(glide.process(0, x));
|
||||
b.push_back(cut.process(0, x));
|
||||
}
|
||||
|
||||
const double localMax = maxAbsDelta(a, boundary - 400, boundary - 1);
|
||||
const double glideStep = std::fabs(static_cast<double>(a[boundary]) -
|
||||
static_cast<double>(a[boundary - 1]));
|
||||
const double cutStep = std::fabs(static_cast<double>(b[boundary]) -
|
||||
static_cast<double>(b[boundary - 1]));
|
||||
// Preserving state keeps the boundary frame inside the signal's own frame-to-frame range;
|
||||
// clearing it does not — which is what proves this assertion discriminates rather than
|
||||
// passing on any pair of numbers.
|
||||
CHECK(glideStep <= localMax);
|
||||
CHECK(cutStep > glideStep * 4.0);
|
||||
}
|
||||
|
||||
// --- Delivery into a sounding voice ------------------------------------------------------
|
||||
|
||||
// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`, applying
|
||||
// `mutate` to the published block after `changeAfter` blocks. Voice-major render order is the
|
||||
// engine's, so a fixed block size is what makes two runs comparable.
|
||||
struct Run {
|
||||
std::vector<AudioSample> out;
|
||||
};
|
||||
|
||||
// The note is an octave above the root on purpose: key-tracking scales (note - root), so a
|
||||
// root-note test would leave the key-track control with nothing to move.
|
||||
static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks,
|
||||
int changeAfter, const LiveValues* changed) {
|
||||
sample.live = block;
|
||||
if (block) block->publish(foldLive(sample.play));
|
||||
VoiceEngine engine(1, sample);
|
||||
engine.noteOn(72, 100);
|
||||
Run r;
|
||||
for (int b = 0; b < blocks; ++b) {
|
||||
if (block && changed && b == changeAfter) block->publish(*changed);
|
||||
engine.render(r.out, static_cast<std::size_t>(blockFrames));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
static SampleData filteredSine() {
|
||||
SampleData s = periodicSine(200000, 64.0);
|
||||
s.play.filter.enabled = true;
|
||||
s.play.filter.settings.cutoffNorm = 0.8f;
|
||||
s.play.filter.settings.resonanceNorm = 0.9f;
|
||||
s.play.filter.settings.morphNorm = 1.0f;
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
return s;
|
||||
}
|
||||
|
||||
static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() {
|
||||
SampleData bare = filteredSine();
|
||||
SampleData blocked = filteredSine();
|
||||
LiveParams block;
|
||||
const Run without = renderWithLive(bare, nullptr, 512, 20, -1, nullptr);
|
||||
const Run with = renderWithLive(blocked, &block, 512, 20, -1, nullptr);
|
||||
CHECK(without.out.size() == with.out.size());
|
||||
bool identical = true;
|
||||
for (std::size_t i = 0; i < without.out.size() && i < with.out.size(); ++i) {
|
||||
if (without.out[i] != with.out[i]) { identical = false; break; }
|
||||
}
|
||||
// Also the migration bar: a blob saved before this change folds to exactly the values the
|
||||
// build already resolved, so reopening it sounds identical rather than merely close.
|
||||
CHECK(identical);
|
||||
}
|
||||
|
||||
static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
||||
struct Case { const char* name; void (*mutate)(LiveValues&); };
|
||||
const Case cases[] = {
|
||||
{"cutoff", [](LiveValues& v) { v.filterSettings.cutoffNorm = 0.15f; }},
|
||||
{"Q", [](LiveValues& v) { v.filterSettings.resonanceNorm = 0.1f; }},
|
||||
{"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }},
|
||||
{"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }},
|
||||
{"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }},
|
||||
{"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }},
|
||||
};
|
||||
|
||||
for (const Case& c : cases) {
|
||||
SampleData still = filteredSine();
|
||||
SampleData moved = filteredSine();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
c.mutate(target);
|
||||
|
||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
|
||||
const Run swept = renderWithLive(moved, &blockB, 512, 24, 8, &target);
|
||||
|
||||
// It moved THIS note: the tail after the publish differs audibly from the untouched
|
||||
// render of the same note.
|
||||
double tailDiff = 0.0;
|
||||
for (std::size_t i = 512 * 12; i < baseline.out.size(); ++i) {
|
||||
tailDiff += std::fabs(static_cast<double>(swept.out[i]) -
|
||||
static_cast<double>(baseline.out[i]));
|
||||
}
|
||||
if (!(tailDiff > 1.0)) std::printf(" (control: %s)\n", c.name);
|
||||
CHECK(tailDiff > 1.0);
|
||||
|
||||
// Nothing before the publish moved (the block is observed at block boundaries only).
|
||||
bool preChangeIdentical = true;
|
||||
for (std::size_t i = 0; i < 512 * 8; ++i) {
|
||||
if (swept.out[i] != baseline.out[i]) { preChangeIdentical = false; break; }
|
||||
}
|
||||
CHECK(preChangeIdentical);
|
||||
|
||||
// And it glided rather than stepping. Measured against the signal's OWN local scale
|
||||
// frame by frame, because a resonant sweep legitimately grows the output as the corner
|
||||
// passes the tone — an absolute delta bound would flag that as a click. A step shows
|
||||
// up instead as one frame far outside the range its own neighbourhood was moving in.
|
||||
// And it ARRIVED as a glide, not as a step. Measured as how far the swept render has
|
||||
// departed from the untouched one in the first frames after the publish, against how
|
||||
// far it departs once settled: a glide has barely begun to diverge, a snapped delivery
|
||||
// is already all the way there.
|
||||
//
|
||||
// This is the assertion that discriminates. A single-frame-spike metric does NOT: the
|
||||
// TPT filter preserves state across prepare(), so even an instantaneous coefficient
|
||||
// jump produces no isolated output spike — measured, by defeating the ramp and
|
||||
// re-running, the spike statistic was unchanged while these two numbers converged.
|
||||
double immediate = 0.0;
|
||||
for (std::size_t i = 512 * 8; i < 512 * 8 + 16; ++i) {
|
||||
immediate = (std::max)(immediate, std::fabs(static_cast<double>(swept.out[i]) -
|
||||
static_cast<double>(baseline.out[i])));
|
||||
}
|
||||
double settled = 0.0;
|
||||
for (std::size_t i = 512 * 14; i < baseline.out.size(); ++i) {
|
||||
settled = (std::max)(settled, std::fabs(static_cast<double>(swept.out[i]) -
|
||||
static_cast<double>(baseline.out[i])));
|
||||
}
|
||||
if (!(immediate <= settled * 0.4)) std::printf(" (glide: %s %f vs %f)\n", c.name,
|
||||
immediate, settled);
|
||||
CHECK(immediate <= settled * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
static void testDrainSlotVoiceTracksTheSameBlock() {
|
||||
// Two snapshots, one block — exactly the processor's live_/draining_ shape. A note ringing
|
||||
// out of the displaced snapshot must answer the knob identically to a live one.
|
||||
SampleData liveSnapshot = filteredSine();
|
||||
SampleData drainSnapshot = filteredSine();
|
||||
LiveParams block;
|
||||
liveSnapshot.live = █
|
||||
drainSnapshot.live = █
|
||||
block.publish(foldLive(liveSnapshot.play));
|
||||
|
||||
VoiceEngine liveEngine(1, liveSnapshot);
|
||||
VoiceEngine drainEngine(1, drainSnapshot);
|
||||
liveEngine.noteOn(60, 100);
|
||||
drainEngine.noteOn(60, 100);
|
||||
|
||||
std::vector<AudioSample> a, b;
|
||||
LiveValues moved = foldLive(liveSnapshot.play);
|
||||
moved.filterSettings.cutoffNorm = 0.2f;
|
||||
for (int blk = 0; blk < 24; ++blk) {
|
||||
if (blk == 8) block.publish(moved);
|
||||
liveEngine.render(a, 512);
|
||||
drainEngine.render(b, 512);
|
||||
}
|
||||
CHECK(a.size() == b.size());
|
||||
bool same = true;
|
||||
for (std::size_t i = 0; i < a.size() && i < b.size(); ++i) {
|
||||
if (a[i] != b[i]) { same = false; break; }
|
||||
}
|
||||
CHECK(same);
|
||||
// Non-tautological: the shared block genuinely moved the sound, so "identical" is a claim
|
||||
// about the drain tracking, not about nothing having happened.
|
||||
double moveEnergy = 0.0;
|
||||
for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]);
|
||||
CHECK(moveEnergy > 1.0);
|
||||
}
|
||||
|
||||
// --- What stays latched at note-on -------------------------------------------------------
|
||||
|
||||
static void testVelocityNoteAndPitchStayLatched() {
|
||||
// A ramp source read under Varispeed: every output frame is (source at readPos) * velocity
|
||||
// gain, so a moved pitch ratio or a moved velocity gain would show up directly.
|
||||
SampleData s;
|
||||
s.frames.resize(100000);
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(static_cast<double>(i) / 100000.0);
|
||||
}
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.velocityCurve = VelocityCurve::linear();
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play));
|
||||
VoiceEngine engine(1, s);
|
||||
engine.noteOn(72, 64); // an octave up: ratio 2.0
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
LiveValues hostile = foldLive(s.play);
|
||||
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
|
||||
// note, the pitch ratio, or the PCM — that is the property under test.
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.0f;
|
||||
hostile.filterModAmount = 1.0;
|
||||
hostile.pitchEnvAttackFrames = 4800;
|
||||
hostile.pitchEnvDecayFrames = 4800;
|
||||
hostile.pitchEnvPeakSemitones = 24.0;
|
||||
hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past
|
||||
for (int blk = 0; blk < 8; ++blk) {
|
||||
if (blk == 2) block.publish(hostile);
|
||||
engine.render(out, 512);
|
||||
}
|
||||
|
||||
const double velocityGain = s.velocityCurve.eval(64.0);
|
||||
bool pitchAndGainHeld = true;
|
||||
for (std::size_t i = 0; i < out.size(); ++i) {
|
||||
const double expected =
|
||||
(static_cast<double>(2 * i) / 100000.0) * velocityGain; // ratio 2.0, latched gain
|
||||
if (std::fabs(static_cast<double>(out[i]) - expected) > 1e-6) {
|
||||
pitchAndGainHeld = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CHECK(pitchAndGainHeld);
|
||||
|
||||
// Positive control on the same rig: a field that IS live does change the output, so the
|
||||
// assertion above is not simply proving the block was ignored wholesale.
|
||||
SampleData s2 = s;
|
||||
LiveParams block2;
|
||||
s2.live = &block2;
|
||||
block2.publish(foldLive(s2.play));
|
||||
VoiceEngine engine2(1, s2);
|
||||
engine2.noteOn(72, 64);
|
||||
std::vector<AudioSample> out2;
|
||||
LiveValues quieter = foldLive(s2.play);
|
||||
quieter.adsr.sustainLevel = 0.25;
|
||||
for (int blk = 0; blk < 8; ++blk) {
|
||||
if (blk == 2) block2.publish(quieter);
|
||||
engine2.render(out2, 512);
|
||||
}
|
||||
CHECK(std::fabs(static_cast<double>(out2.back()) - static_cast<double>(out.back())) > 1e-4);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStageDurationChangeHoldsPhase();
|
||||
testShortenedStageStillLandsContinuously();
|
||||
testSustainLevelChangeGlides();
|
||||
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
|
||||
testCutoffMoveAcrossPrepareDoesNotStep();
|
||||
testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
|
||||
testEveryLiveFilterControlMovesTheSoundingNote();
|
||||
testDrainSlotVoiceTracksTheSameBlock();
|
||||
testVelocityNoteAndPitchStayLatched();
|
||||
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -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