instrument: snap live params onto a fresh voice, roll a live drag back on capture loss, serialize the seqlock's two writers

This commit is contained in:
2026-07-30 21:39:56 -04:00
parent 1dade0bfcf
commit bbc7dc70bb
15 changed files with 621 additions and 143 deletions
+375 -53
View File
@@ -1,9 +1,10 @@
// 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.
// 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"
@@ -48,6 +49,62 @@ static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, s
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;
s.play.filter.env.sustainLevel = 1.0;
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() {
@@ -120,7 +177,10 @@ static void testSustainLevelChangeGlides() {
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
// 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;
@@ -162,6 +222,222 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
CHECK(c.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(stale);
env.noteOn();
env.snapLive(0, 1000, 12.0);
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 = &block;
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.attackFrames = 48000;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1},
{"pitch env decay",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1},
{"pitch env depth",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 480000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvPeakSemitones = 0.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() {
@@ -203,39 +479,6 @@ static void testCutoffMoveAcrossPrepareDoesNotStep() {
// --- 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();
@@ -290,10 +533,6 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
}
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
@@ -303,8 +542,21 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
// 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 six 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 + 16; ++i) {
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])));
}
@@ -313,15 +565,18 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
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);
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 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.
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;
@@ -348,18 +603,27 @@ static void testDrainSlotVoiceTracksTheSameBlock() {
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.
// 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 testVelocityNoteAndPitchStayLatched() {
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) {
@@ -422,16 +686,74 @@ static void testVelocityNoteAndPitchStayLatched() {
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. velAmount is 0, 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.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.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.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 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();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep();
testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
testANoteStartedAfterAPublishSoundsThePublishedEnvelope();
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
testEveryLiveFilterControlMovesTheSoundingNote();
testDrainSlotVoiceTracksTheSameBlock();
testVelocityNoteAndPitchStayLatched();
testOneBlockServesTwoIndependentObservers();
testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n");
return g_fail == 0 ? 0 : 1;
}