867 lines
37 KiB
C++
867 lines
37 KiB
C++
// 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
|
|
// fresh note takes the newest block outright, every stage time and stage level on all three
|
|
// envelopes moves the note already sounding, a filter knob does too, 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;
|
|
}
|
|
|
|
static double peakOf(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
|
|
double peak = 0.0;
|
|
for (std::size_t i = from; i < to && i < v.size(); ++i) {
|
|
peak = (std::max)(peak, std::fabs(static_cast<double>(v[i])));
|
|
}
|
|
return peak;
|
|
}
|
|
|
|
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;
|
|
// A drawn velocity curve, so the velocity-DEPTH knob has something to scale. It costs
|
|
// every other case nothing: the depth is 0 until a case moves it, so the product is 0.
|
|
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
|
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
|
|
return s;
|
|
}
|
|
|
|
// A low corner with real envelope depth, so the filter ENVELOPE's shape is what the timbre
|
|
// depends on rather than the static knob position.
|
|
static void filterSweep(SampleData& s) {
|
|
s.play.filter.enabled = true;
|
|
s.play.filter.settings.cutoffNorm = 0.15f;
|
|
s.play.filter.settings.resonanceNorm = 0.6f;
|
|
s.play.filter.settings.morphNorm = 1.0f;
|
|
s.play.filter.modAmount = 0.8;
|
|
}
|
|
|
|
// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`,
|
|
// republishing `changed` at the top of block `changeAfter` and gating the note off at the top
|
|
// of `noteOffBlock` (-1 holds it). 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.
|
|
constexpr int kTestNote = 72;
|
|
|
|
static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks,
|
|
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
|
|
int velocity = 100) {
|
|
sample.live = block;
|
|
if (block) block->publish(foldLive(sample.play));
|
|
VoiceEngine engine(1, sample);
|
|
engine.noteOn(kTestNote, velocity);
|
|
Run r;
|
|
for (int b = 0; b < blocks; ++b) {
|
|
if (block && changed && b == changeAfter) block->publish(*changed);
|
|
if (b == noteOffBlock) engine.noteOff(kTestNote);
|
|
engine.render(r.out, static_cast<std::size_t>(blockFrames));
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// --- 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();
|
|
// The first frame reproduces the pre-change level. Bounded rather than compared
|
|
// exactly: 0.2 + fl(1.0 - 0.2) does round to exactly 1.0 for THESE operands, but the
|
|
// property under test is continuity, not a bit-exactness the smoother never promised.
|
|
if (i == 0) CHECK(std::fabs(v - 1.0) < 1e-15);
|
|
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() {
|
|
// No hold stage, so the shape is the attack-decay one the pre-AHD envelope had.
|
|
PitchEnvParams p;
|
|
p.enabled = true;
|
|
p.peakSemitones = 12.0;
|
|
p.shape.attackFrames = 0;
|
|
p.shape.decayFrames = 1000;
|
|
p.shape.holdFraction = 0.0;
|
|
PitchEnvelope a, b;
|
|
a.configure(100000, p);
|
|
b.configure(100000, p);
|
|
a.noteOn();
|
|
b.noteOn();
|
|
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
|
|
|
|
PitchEnvParams longer = p;
|
|
longer.shape.decayFrames = 2000;
|
|
b.applyLive(longer); // 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(100000, p);
|
|
d.configure(100000, p);
|
|
c.noteOn();
|
|
d.noteOn();
|
|
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
|
|
PitchEnvParams noDepth = p;
|
|
noDepth.peakSemitones = 0.0;
|
|
c.applyLive(noDepth); // 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 pitch envelope's new middle stage, on the same phi rule: a hold dialled mid-hold keeps
|
|
// the level (flat by definition) and moves the boundary, and the fraction is taken against
|
|
// what attack and decay left rather than against the whole span.
|
|
static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
|
|
PitchEnvParams p;
|
|
p.enabled = true;
|
|
p.peakSemitones = 12.0;
|
|
p.shape.attackFrames = 100;
|
|
p.shape.decayFrames = 100;
|
|
p.shape.holdFraction = 0.5; // half of (1000 - 200) = 400 frames of hold
|
|
PitchEnvelope e;
|
|
e.configure(1000, p);
|
|
e.noteOn();
|
|
for (int i = 0; i < 100; ++i) e.tick(); // through the attack
|
|
CHECK(e.tick() == 12.0); // frame 100: at the peak, holding
|
|
for (int i = 0; i < 398; ++i) e.tick(); // to the last frame of the hold
|
|
CHECK(e.tick() == 12.0); // frame 499: still holding
|
|
CHECK(e.tick() == 12.0); // frame 500: decay's own first frame
|
|
CHECK(std::fabs(e.tick() - 12.0 * (1.0 - 1.0 / 100.0)) < 1e-12); // frame 501: descending
|
|
|
|
// A live hold change mid-hold is continuous (the stage is flat) and the envelope still
|
|
// finishes inside the span.
|
|
PitchEnvelope f;
|
|
f.configure(1000, p);
|
|
f.noteOn();
|
|
for (int i = 0; i < 300; ++i) f.tick();
|
|
PitchEnvParams wider = p;
|
|
wider.shape.holdFraction = 1.0;
|
|
f.applyLive(wider);
|
|
CHECK(f.tick() == 12.0);
|
|
for (int i = 0; i < 1200; ++i) f.tick();
|
|
CHECK(f.tick() == 0.0);
|
|
}
|
|
|
|
// --- The fresh-note path: snap, never the phi rule ---------------------------------------
|
|
|
|
static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() {
|
|
// Regression, both directions. The snap path once ran applyLive's phi rule, which reads a
|
|
// stale duration of 0 as "this stage is already complete" and threw the newly-dialled
|
|
// attack away for every note until the next reload.
|
|
AdsrParams stale; // the AdsrParams default: every stage zero
|
|
stale.sustainLevel = 1.0;
|
|
AdsrEnvelope env;
|
|
env.configure(stale);
|
|
env.noteOn();
|
|
AdsrParams dialled = stale;
|
|
dialled.attackFrames = 100;
|
|
env.snapLive(dialled);
|
|
CHECK(env.tick() == 0.0); // frame 0 of a 100-frame attack, not an instant 1.0
|
|
for (int i = 0; i < 49; ++i) env.tick();
|
|
CHECK(std::fabs(env.tick() - 0.5) < 1e-12);
|
|
|
|
// Reverse: a stale non-zero attack against a newly-dialled ZERO one must not absorb a
|
|
// full-scale step into a voice that has emitted nothing — that fades in a note the user
|
|
// asked to be instant.
|
|
AdsrParams staleLong;
|
|
staleLong.attackFrames = 1000;
|
|
staleLong.sustainLevel = 1.0;
|
|
AdsrEnvelope instant;
|
|
instant.configure(staleLong);
|
|
instant.noteOn();
|
|
AdsrParams zeroAttack = staleLong;
|
|
zeroAttack.attackFrames = 0;
|
|
instant.snapLive(zeroAttack);
|
|
CHECK(instant.tick() == 1.0);
|
|
}
|
|
|
|
static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
|
|
PitchEnvParams stale; // enabled, but every leg zero
|
|
stale.enabled = true;
|
|
PitchEnvelope env;
|
|
env.configure(100000, stale);
|
|
env.noteOn();
|
|
PitchEnvParams dialled = stale;
|
|
dialled.peakSemitones = 12.0;
|
|
dialled.shape.decayFrames = 1000;
|
|
env.snapLive(dialled);
|
|
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
|
|
for (int i = 0; i < 499; ++i) env.tick();
|
|
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
|
|
}
|
|
|
|
static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
|
// End-to-end shape of the snap path: a live commit deliberately leaves the snapshot's own
|
|
// sample.play stale, so the ONLY thing standing between a new note and a stale envelope is
|
|
// the snap. This is the coverage whose absence let the phi-on-snap bug through.
|
|
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
|
|
LiveParams block;
|
|
s.live = █
|
|
LiveValues dialled = foldLive(s.play);
|
|
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
|
|
block.publish(dialled);
|
|
VoiceEngine engine(1, s);
|
|
engine.noteOn(kTestNote, 100);
|
|
std::vector<AudioSample> out;
|
|
engine.render(out, 512);
|
|
|
|
// Control: the same stale snapshot with no block at all speaks at full level immediately.
|
|
SampleData bare = periodicSine(200000, 64.0);
|
|
VoiceEngine bareEngine(1, bare);
|
|
bareEngine.noteOn(kTestNote, 100);
|
|
std::vector<AudioSample> bareOut;
|
|
bareEngine.render(bareOut, 512);
|
|
const double barePeak = peakOf(bareOut, 0, bareOut.size());
|
|
const double peak = peakOf(out, 0, out.size());
|
|
CHECK(barePeak > 0.9);
|
|
CHECK(peak < barePeak * 0.1); // 512 frames into a 24000-frame attack: ~2% of full scale
|
|
|
|
// Reverse: a stale LONG attack against a published zero one. The note must speak at full
|
|
// level within its first cycle rather than fading in over the smoother's decay.
|
|
SampleData slow = periodicSine(200000, 64.0);
|
|
slow.play.adsr.attackFrames = 24000;
|
|
LiveParams block2;
|
|
slow.live = &block2;
|
|
LiveValues snappy = foldLive(slow.play);
|
|
snappy.adsr.attackFrames = 0;
|
|
block2.publish(snappy);
|
|
VoiceEngine fast(1, slow);
|
|
fast.noteOn(kTestNote, 100);
|
|
std::vector<AudioSample> fastOut;
|
|
fast.render(fastOut, 512);
|
|
// Source period 64 read at ratio 2 peaks at output frame 8; a spurious smoother fade-in
|
|
// would still be at ~0.34 there.
|
|
CHECK(peakOf(fastOut, 0, 32) > 0.9);
|
|
}
|
|
|
|
// --- Every envelope stage, end to end through the engine ---------------------------------
|
|
|
|
// Renders the same note twice — once untouched, once with `mutate` published mid-note — and
|
|
// asserts the field reached the SOUNDING voice (the tail diverges) and only after its publish.
|
|
static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(SampleData&),
|
|
void (*mutate)(LiveValues&), int noteOffBlock) {
|
|
SampleData still = periodicSine(200000, 64.0);
|
|
SampleData moved = periodicSine(200000, 64.0);
|
|
rig(still);
|
|
rig(moved);
|
|
LiveParams blockA, blockB;
|
|
LiveValues target = foldLive(moved.play);
|
|
mutate(target);
|
|
|
|
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
|
|
const Run edited = renderWithLive(moved, &blockB, 512, 24, 8, &target, noteOffBlock);
|
|
|
|
CHECK(baseline.out.size() == edited.out.size());
|
|
double tailDiff = 0.0;
|
|
for (std::size_t i = 512 * 9; i < baseline.out.size() && i < edited.out.size(); ++i) {
|
|
tailDiff += std::fabs(static_cast<double>(edited.out[i]) -
|
|
static_cast<double>(baseline.out[i]));
|
|
}
|
|
if (!(tailDiff > 1.0)) std::printf(" (never reached the voice: %s)\n", name);
|
|
CHECK(tailDiff > 1.0);
|
|
|
|
bool preChangeIdentical = true;
|
|
for (std::size_t i = 0; i < 512 * 8 && i < baseline.out.size(); ++i) {
|
|
if (edited.out[i] != baseline.out[i]) { preChangeIdentical = false; break; }
|
|
}
|
|
if (!preChangeIdentical) std::printf(" (moved before its publish: %s)\n", name);
|
|
CHECK(preChangeIdentical);
|
|
}
|
|
|
|
static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
|
|
// Each rig puts the voice INSIDE the stage under test at the publish (block 8, output
|
|
// frame 4096) — a stage already passed cannot move, which is the physics, not a gap.
|
|
struct Case {
|
|
const char* name;
|
|
void (*rig)(SampleData&);
|
|
void (*mutate)(LiveValues&);
|
|
int noteOffBlock;
|
|
};
|
|
const Case cases[] = {
|
|
{"amp attack",
|
|
[](SampleData& s) { s.play.adsr.attackFrames = 48000; },
|
|
[](LiveValues& v) { v.adsr.attackFrames = 4000; }, -1},
|
|
{"amp hold",
|
|
[](SampleData& s) {
|
|
s.play.adsr.holdFrames = 48000;
|
|
s.play.adsr.decayFrames = 4000;
|
|
s.play.adsr.sustainLevel = 0.1;
|
|
},
|
|
[](LiveValues& v) { v.adsr.holdFrames = 5000; }, -1},
|
|
{"amp decay",
|
|
[](SampleData& s) {
|
|
s.play.adsr.decayFrames = 48000;
|
|
s.play.adsr.sustainLevel = 0.0;
|
|
},
|
|
[](LiveValues& v) { v.adsr.decayFrames = 8000; }, -1},
|
|
{"amp sustain",
|
|
[](SampleData& s) { s.play.adsr.sustainLevel = 1.0; },
|
|
[](LiveValues& v) { v.adsr.sustainLevel = 0.2; }, -1},
|
|
{"amp release",
|
|
[](SampleData& s) { s.play.adsr.releaseFrames = 48000; },
|
|
[](LiveValues& v) { v.adsr.releaseFrames = 6000; }, 2},
|
|
|
|
// The filter envelope: swept over a low corner with real depth, so its shape is the
|
|
// only thing the timbre depends on. The amp release is long so a gated-off voice
|
|
// keeps sounding while the filter release is measured.
|
|
{"filter env attack",
|
|
[](SampleData& s) { filterSweep(s); s.play.filter.env.attackFrames = 48000; },
|
|
[](LiveValues& v) { v.filterEnv.attackFrames = 4000; }, -1},
|
|
{"filter env hold",
|
|
[](SampleData& s) {
|
|
filterSweep(s);
|
|
s.play.filter.env.holdFrames = 48000;
|
|
s.play.filter.env.decayFrames = 4000;
|
|
s.play.filter.env.sustainLevel = 0.0;
|
|
},
|
|
[](LiveValues& v) { v.filterEnv.holdFrames = 5000; }, -1},
|
|
{"filter env decay",
|
|
[](SampleData& s) {
|
|
filterSweep(s);
|
|
s.play.filter.env.decayFrames = 48000;
|
|
s.play.filter.env.sustainLevel = 0.0;
|
|
},
|
|
[](LiveValues& v) { v.filterEnv.decayFrames = 8000; }, -1},
|
|
{"filter env sustain",
|
|
[](SampleData& s) { filterSweep(s); },
|
|
[](LiveValues& v) { v.filterEnv.sustainLevel = 0.0; }, -1},
|
|
{"filter env release",
|
|
[](SampleData& s) {
|
|
filterSweep(s);
|
|
s.play.filter.env.releaseFrames = 48000;
|
|
s.play.adsr.releaseFrames = 480000;
|
|
},
|
|
[](LiveValues& v) { v.filterEnv.releaseFrames = 6000; }, 2},
|
|
|
|
{"pitch env attack",
|
|
[](SampleData& s) {
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.shape.attackFrames = 48000;
|
|
s.play.pitchEnv.shape.decayFrames = 48000;
|
|
s.play.pitchEnv.peakSemitones = 12.0;
|
|
},
|
|
[](LiveValues& v) { v.pitchEnv.shape.attackFrames = 4000; }, -1},
|
|
{"pitch env decay",
|
|
[](SampleData& s) {
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.shape.decayFrames = 48000;
|
|
s.play.pitchEnv.peakSemitones = 12.0;
|
|
},
|
|
[](LiveValues& v) { v.pitchEnv.shape.decayFrames = 8000; }, -1},
|
|
{"pitch env depth",
|
|
[](SampleData& s) {
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.shape.decayFrames = 480000;
|
|
s.play.pitchEnv.peakSemitones = 12.0;
|
|
},
|
|
[](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1},
|
|
// A small hold that finishes the envelope well inside the render window (Hold ends at
|
|
// frame ~5090, comfortably short of the window) vs. a live move that opens the hold out
|
|
// near the whole span: with the fraction alone unmoved, the boundary the two renders
|
|
// cross (or don't) inside the observed tail is what makes this audible, not a level
|
|
// change — Hold's own output is flat regardless of exactly where inside it pos_ sits.
|
|
{"pitch env hold fraction",
|
|
[](SampleData& s) {
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.peakSemitones = 12.0;
|
|
s.play.pitchEnv.shape.attackFrames = 100;
|
|
s.play.pitchEnv.shape.decayFrames = 100;
|
|
s.play.pitchEnv.shape.holdFraction = 0.05;
|
|
},
|
|
[](LiveValues& v) { v.pitchEnv.shape.holdFraction = 1.0; }, -1},
|
|
|
|
// The Trigger AHDs — Voice::applyLive's ampAhd_/filterAhd_ branches, otherwise
|
|
// unexercised by this table (every case above is Gate/AdsrEnvelope).
|
|
{"trigger amp attack (AHD)",
|
|
[](SampleData& s) {
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigger.lengthFraction = 1.0;
|
|
s.play.trigAhd.attackFrames = 48000;
|
|
},
|
|
[](LiveValues& v) { v.ampAhd.attackFrames = 4000; }, -1},
|
|
{"trigger filter attack (AHD)",
|
|
[](SampleData& s) {
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigger.lengthFraction = 1.0;
|
|
filterSweep(s);
|
|
s.play.filter.trigEnv.attackFrames = 48000;
|
|
},
|
|
[](LiveValues& v) { v.filterAhd.attackFrames = 4000; }, -1},
|
|
|
|
// One curve exponent per envelope (amp/pitch/filter), reusing each envelope's own
|
|
// attack/decay rig above so only the mutated field differs.
|
|
{"amp attack curve",
|
|
[](SampleData& s) { s.play.adsr.attackFrames = 48000; },
|
|
[](LiveValues& v) { v.adsr.attackCurve = 5.0; }, -1},
|
|
{"amp release curve",
|
|
[](SampleData& s) { s.play.adsr.releaseFrames = 48000; },
|
|
[](LiveValues& v) { v.adsr.releaseCurve = 5.0; }, 2},
|
|
{"pitch env attack curve",
|
|
[](SampleData& s) {
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.shape.attackFrames = 48000;
|
|
s.play.pitchEnv.shape.decayFrames = 48000;
|
|
s.play.pitchEnv.peakSemitones = 12.0;
|
|
},
|
|
[](LiveValues& v) { v.pitchEnv.shape.attackCurve = 5.0; }, -1},
|
|
{"filter env decay curve",
|
|
[](SampleData& s) {
|
|
filterSweep(s);
|
|
s.play.filter.env.decayFrames = 48000;
|
|
s.play.filter.env.sustainLevel = 0.0;
|
|
},
|
|
[](LiveValues& v) { v.filterEnv.decayCurve = 5.0; }, -1},
|
|
};
|
|
for (const Case& c : cases) {
|
|
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
|
|
}
|
|
}
|
|
|
|
// --- 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 ------------------------------------------------------
|
|
|
|
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; }},
|
|
// The velocity DEPTH is live even though the velocity itself is latched: the depth is
|
|
// a control over the note's latched curve value, the same shape key-track has over the
|
|
// note's latched number (deck_groups.h).
|
|
{"velamount", [](LiveValues& v) { v.filterVelAmount = 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 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.
|
|
//
|
|
// The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the
|
|
// full travel time, so at 1/240 of it a working glide has barely started when the
|
|
// window closes. kGlideMargin then puts the bound at the geometric middle of the two
|
|
// MEASURED populations — with the ramp in place these seven controls ratio 0.0005..0.060;
|
|
// with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10.
|
|
// The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap.
|
|
const std::size_t rampFrames =
|
|
static_cast<std::size_t>(instrument::engine::kLiveRampSeconds * kRate);
|
|
const std::size_t window = rampFrames / 240;
|
|
const double kGlideMargin = 42.0;
|
|
const double bound = kGlideMargin * static_cast<double>(window) /
|
|
static_cast<double>(rampFrames);
|
|
double immediate = 0.0;
|
|
for (std::size_t i = 512 * 8; i < 512 * 8 + window; ++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 * bound))
|
|
std::printf(" (glide: %s ratio %f vs bound %f)\n", c.name,
|
|
settled > 0.0 ? immediate / settled : -1.0, bound);
|
|
CHECK(immediate <= settled * bound);
|
|
}
|
|
}
|
|
|
|
static void testOneBlockServesTwoIndependentObservers() {
|
|
// Two snapshots, one block — exactly the processor's live_/draining_ shape. The claim is
|
|
// narrow and specific: read() does NOT consume the generation, so the second engine to
|
|
// observe a publish sees it as fully as the first. Two identically-built engines are
|
|
// otherwise identical by construction, so that is the only thing the comparison pins.
|
|
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);
|
|
// The shared block genuinely moved the sound, so "identical" is a claim about both
|
|
// observers having seen it rather than 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);
|
|
// And a THIRD observer, after both engines have read it, still sees the same publish.
|
|
LiveValues seen;
|
|
CHECK(block.read(seen) != 0);
|
|
CHECK(seen.filterSettings.cutoffNorm == 0.2f);
|
|
}
|
|
|
|
// --- What stays latched at note-on -------------------------------------------------------
|
|
|
|
static void testPitchRatioAndVelocityGainStayLatched() {
|
|
// 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.
|
|
//
|
|
// The filter and the pitch envelope are OFF here on purpose — that is what makes the read
|
|
// rate provable arithmetic. It also means the block's filter and pitch-envelope fields
|
|
// cannot land on this voice; that they DO land on a voice that has them enabled, and still
|
|
// leave the velocity gain alone, is the next test's job.
|
|
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.filterVelAmount = 1.0;
|
|
hostile.pitchEnv.shape.attackFrames = 4800;
|
|
hostile.pitchEnv.shape.decayFrames = 4800;
|
|
hostile.pitchEnv.peakSemitones = 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);
|
|
}
|
|
|
|
static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
|
// Filter AND pitch envelope enabled, so every field the block carries actually reaches the
|
|
// voice. The filter and pitch velocity curves are left at their off defaults, so velocity
|
|
// enters the render exactly once — as the amp gain latched at note-on — which makes two
|
|
// runs at different velocities exactly proportional unless the publish moved that gain (a
|
|
// re-derived gain would have to preserve the ratio 100:64 to slip through).
|
|
SampleData rig = periodicSine(200000, 64.0);
|
|
rig.velocityCurve = VelocityCurve::linear();
|
|
filterSweep(rig);
|
|
rig.play.pitchEnv.enabled = true;
|
|
rig.play.pitchEnv.shape.decayFrames = 24000;
|
|
rig.play.pitchEnv.peakSemitones = 3.0;
|
|
|
|
LiveValues hostile = foldLive(rig.play);
|
|
hostile.filterKeyTrack = 2.0;
|
|
hostile.filterSettings.cutoffNorm = 0.9f;
|
|
hostile.filterModAmount = -1.0;
|
|
hostile.filterEnv.decayFrames = 4800;
|
|
hostile.filterEnv.sustainLevel = 0.0;
|
|
hostile.pitchEnv.shape.attackFrames = 4800;
|
|
hostile.pitchEnv.shape.decayFrames = 4800;
|
|
hostile.pitchEnv.peakSemitones = 24.0;
|
|
hostile.adsr.sustainLevel = 0.4;
|
|
|
|
SampleData quiet = rig, loud = rig, untouched = rig;
|
|
LiveParams blockQuiet, blockLoud, blockUntouched;
|
|
const Run atQuiet = renderWithLive(quiet, &blockQuiet, 512, 16, 2, &hostile, -1, 64);
|
|
const Run atLoud = renderWithLive(loud, &blockLoud, 512, 16, 2, &hostile, -1, 100);
|
|
const Run noPublish = renderWithLive(untouched, &blockUntouched, 512, 16, -1, nullptr, -1, 64);
|
|
|
|
// The publish is not inert: it moved the note it was published into.
|
|
double landed = 0.0;
|
|
for (std::size_t i = 512 * 3; i < atQuiet.out.size() && i < noPublish.out.size(); ++i) {
|
|
landed += std::fabs(static_cast<double>(atQuiet.out[i]) -
|
|
static_cast<double>(noPublish.out[i]));
|
|
}
|
|
CHECK(landed > 1.0);
|
|
|
|
// ...and through all of it the two velocities differ by exactly the curve's ratio.
|
|
const double ratio = rig.velocityCurve.eval(100.0) / rig.velocityCurve.eval(64.0);
|
|
CHECK(ratio > 1.5); // the curve really does separate these two velocities
|
|
bool proportional = true;
|
|
for (std::size_t i = 0; i < atQuiet.out.size() && i < atLoud.out.size(); ++i) {
|
|
if (std::fabs(static_cast<double>(atLoud.out[i]) -
|
|
static_cast<double>(atQuiet.out[i]) * ratio) > 1e-6) {
|
|
proportional = false;
|
|
break;
|
|
}
|
|
}
|
|
CHECK(proportional);
|
|
}
|
|
|
|
int main() {
|
|
testStageDurationChangeHoldsPhase();
|
|
testShortenedStageStillLandsContinuously();
|
|
testSustainLevelChangeGlides();
|
|
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
|
|
testPitchEnvelopeHoldStagePlaysAndHoldsPhase();
|
|
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
|
|
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
|
|
testCutoffMoveAcrossPrepareDoesNotStep();
|
|
testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
|
|
testANoteStartedAfterAPublishSoundsThePublishedEnvelope();
|
|
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
|
|
testEveryLiveFilterControlMovesTheSoundingNote();
|
|
testOneBlockServesTwoIndependentObservers();
|
|
testPitchRatioAndVelocityGainStayLatched();
|
|
testVelocityGainSurvivesAHostilePublishThatReallyLands();
|
|
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
|
return g_fail == 0 ? 0 : 1;
|
|
}
|