Files
reasampler/tests/test_live_contours.cpp
T
daniel f7505d0e53 Make spline EG drags audible live: held and new notes follow a dragged knot
Contours ride their own seqlock block into a double-buffered engine copy, and the swap step is smoothed. Each drag move publishes contours without a model write; release still commits and reloads.
2026-09-25 13:42:25 -04:00

439 lines
19 KiB
C++

// Standalone tests for LIVE CONTOUR DELIVERY — a drawn EG edited while notes sound — no VST3,
// no REAPER, no framework. Asserts that each of the three contours (amp, pitch, filter) moves
// the note already sounding and the next one struck, that the swap is absorbed rather than
// stepped, that what start() latched (a Staged EG) stays latched, that an unchanged publish is
// byte-identical, that two engines sharing one block both follow (the drain slot), and that the
// whole audio-side path allocates nothing. This executable replaces the global allocator to
// prove the last one, which is why these tests are not in live_delivery_tests.
#include "../src/core/instrument/engine/live_contours.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <vector>
using namespace reasampler;
using instrument::engine::ContourPoints;
using instrument::engine::CurveDomain;
using instrument::engine::LiveContourBlock;
using instrument::engine::contourPoints;
using instrument::engine::foldContours;
// --- Allocation counter -----------------------------------------------------------------
// Counts every global allocation while armed. Replacing the global operator new is the only way
// to observe allocations made inside sampler_core without instrumenting it.
static std::atomic<long> g_allocations{0};
static std::atomic<bool> g_countAllocations{false};
static void* countedAlloc(std::size_t n) {
if (g_countAllocations.load(std::memory_order_relaxed)) {
g_allocations.fetch_add(1, std::memory_order_relaxed);
}
if (void* p = std::malloc(n ? n : 1)) return p;
throw std::bad_alloc();
}
void* operator new(std::size_t n) { return countedAlloc(n); }
void* operator new[](std::size_t n) { return countedAlloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete[](void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
void operator delete[](void* p, std::size_t) noexcept { std::free(p); }
// ------------------------------------------------------------------------------------------
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;
constexpr std::size_t kBlock = 256;
constexpr int kRoot = 60;
// Every frame 1.0, Trigger (a drawn EG is a Trigger fact — resolvePlay's job on the real path),
// played at the root with a flat velocity curve: an output frame IS the amplitude.
static SampleData dcSample(std::size_t frames) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
return s;
}
static VelocityCurve flatAt(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {127.0, y}}, domain);
}
// Three points rather than two, so a publish that changes the point COUNT is what lands.
static VelocityCurve flatAt3(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {63.0, y}, {127.0, y}}, domain);
}
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) {
worst = (std::max)(worst, std::fabs(static_cast<double>(v[i]) - v[i - 1]));
}
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;
}
// One held note through a one-voice engine observing `block`, with `edited`'s contours
// published at the top of block `swapBlock` (-1: never). The initial publish is `sample.play`'s
// own contours, the state a reload leaves.
static std::vector<AudioSample> renderHeld(SampleData& sample, LiveContourBlock& block,
const PlayParams* edited, int swapBlock, int blocks) {
sample.liveContours = &block;
block.publish(foldContours(sample.play));
VoiceEngine engine(1, sample);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < blocks; ++b) {
if (edited && b == swapBlock) block.publish(foldContours(*edited));
engine.render(out, kBlock);
}
return out;
}
// --- The fold ------------------------------------------------------------------------------
static void testTheFoldCarriesEveryPointAndItsHardFlag() {
std::vector<VelocityPoint> pts = {{0.0, 0.1}, {40.0, 0.9}, {90.0, 0.2}, {127.0, 0.7}};
VelocityCurve c = VelocityCurve::fromPoints(pts, CurveDomain::Unipolar);
c.setHard(2, true);
const ContourPoints folded = contourPoints(c);
CHECK(folded.count == 4);
for (std::size_t i = 0; i < 4; ++i) {
CHECK(folded.points[i].velocity == c.points()[i].velocity);
CHECK(folded.points[i].value == c.points()[i].value);
CHECK(folded.points[i].hard == c.points()[i].hard);
}
CHECK(folded.points[2].hard);
PlayParams p;
p.ampSpline.contour = flatAt(0.3, CurveDomain::Unipolar);
p.pitchSpline.contour = c;
p.filterSpline.contour = flatAt3(0.6, CurveDomain::Unipolar);
const auto all = foldContours(p);
CHECK(all.amp.count == 2 && all.amp.points[1].value == 0.3);
CHECK(all.pitch.count == 4 && all.pitch.points[1].value == c.points()[1].value);
CHECK(all.filter.count == 3 && all.filter.points[2].value == 0.6);
}
// --- Amp -----------------------------------------------------------------------------------
static void testAHeldNoteFollowsAnAmpContourEditWithoutAStep() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
CHECK(moved[swapAt - 1] == 1.0f);
// Absorbed: the first frame read through the new contour reproduces the old level exactly...
CHECK(moved[swapAt] == 1.0f);
// ...and glides from there. An unabsorbed swap would step by 0.75 in one frame.
CHECK(maxAbsDelta(moved, swapAt - 1, swapAt + 64) < 0.05);
CHECK(moved[swapAt + 32] < 0.9f);
// The held note lands on the edited contour, where the unedited run stays on the old one.
CHECK(std::fabs(moved.back() - 0.25) < 1e-6);
CHECK(still.back() == 1.0f);
}
// The processor marshals note-ons BEFORE the block renders, so a publish and a note-on landing
// in one block reach the engine through startVoice first. The voice already sounding must be
// rebound there too: left on the buffer the read just displaced, it would never see this edit.
static void testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(2, s);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < 4; ++b) engine.render(out, kBlock);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127); // the second voice, struck in the swap block
for (int b = 0; b < 8; ++b) engine.render(out, kBlock);
const std::size_t swapAt = 4 * kBlock;
// The sounding voice's absorbed 1.0 plus the fresh voice's snapped 0.25 — the fresh one
// takes the edit outright even though the snapshot still carries the old contour.
CHECK(std::fabs(out[swapAt] - 1.25) < 1e-6);
// Both land on the edit: 0.25 + 0.25. A sounding voice that missed it would leave 1.25.
CHECK(std::fabs(out.back() - 0.5) < 1e-6);
}
static void testANoteStruckAfterAnEditPlaysItFromItsFirstFrame() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(1, s);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
engine.render(out, kBlock);
CHECK(out[0] == 0.25f);
// The snapshot really does still say 1.0: with no block the same engine plays it.
SampleData bare = s;
bare.liveContours = nullptr;
VoiceEngine bareEngine(1, bare);
bareEngine.noteOn(kRoot, 127);
std::vector<AudioSample> bareOut;
bareEngine.render(bareOut, kBlock);
CHECK(bareOut[0] == 1.0f);
}
// --- Pitch ---------------------------------------------------------------------------------
// A ramp source under Varispeed: an output frame is the read head, so the frame-to-frame step
// IS the pitch ratio.
static void testAHeldNoteFollowsAPitchContourEdit() {
SampleData s;
s.frames.resize(1u << 15);
for (std::size_t i = 0; i < s.frames.size(); ++i) s.frames[i] = static_cast<float>(i);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
PlayParams edited = s.play;
edited.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); // +1 x 12 st = an octave
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
const std::size_t n = moved.size();
CHECK(std::fabs((moved[swapAt - 1] - moved[swapAt - 2]) - 1.0) < 1e-3);
CHECK(std::fabs((moved[n - 1] - moved[n - 2]) - 2.0) < 1e-3);
CHECK(std::fabs((still[n - 1] - still[n - 2]) - 1.0) < 1e-3);
// Absorbed here too: the first frame read through the new contour still advances at the old
// ratio, where an unabsorbed swap would already step by 2.
CHECK(std::fabs((moved[swapAt + 1] - moved[swapAt]) - 1.0) < 1e-3);
}
// --- Filter --------------------------------------------------------------------------------
static void testAHeldNoteFollowsAFilterContourEdit() {
SampleData s;
s.frames.resize(100000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 8.0));
}
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.1f;
s.play.filter.settings.morphNorm = 1.0f; // low-pass
s.play.filter.modAmount = 0.8;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); // corner 0.1 -> 0.9
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 16);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 16);
const std::size_t tail = moved.size() - 1024;
const double openedPeak = peakOf(moved, tail, moved.size());
const double closedPeak = peakOf(still, tail, still.size());
// A 6 kHz tone through the corner the edit opened, against the unedited note's, which stays
// filtered away (measured ~0.18 vs ~0.0002 — the margins below are loose on purpose).
CHECK(openedPeak > 0.1);
CHECK(openedPeak > 10.0 * closedPeak);
}
// --- What stays latched, and what must not move --------------------------------------------
// Staged|Spline is a note-on fact: an EG that start() left Staged ignores any contour publish.
static void testAStagedEnvelopeIgnoresContourPublishes() {
SampleData s = dcSample(200000);
// A staged shape that is not flat from frame 0, so a leak would show.
s.play.trigAhd.decayFrames = 20000;
s.play.trigAhd.holdFraction = 0.0;
PlayParams hostile = s.play;
hostile.ampSpline.contour = flatAt3(0.0, CurveDomain::Unipolar);
hostile.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar);
hostile.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> published = renderHeld(s, blockA, &hostile, 3, 10);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 10);
CHECK(published == none);
CHECK(none[3 * kBlock + 100] < 1.0f); // the staged decay is really running
}
// Republishing the contour a voice is already reading rebinds it mid-read; the read must not
// move by a bit. A curved, multi-segment contour with a hard point so the re-located segment
// and its tangents are genuinely exercised.
static void testRepublishingAnUnchangedContourIsByteIdentical() {
SampleData s = dcSample(20000);
s.play.ampSpline.mode = EnvMode::Spline;
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.0}, {20.0, 1.0}, {60.0, 0.3}, {100.0, 0.9}, {127.0, 0.0}},
CurveDomain::Unipolar);
c.setHard(2, true);
s.play.ampSpline.contour = c;
const PlayParams same = s.play;
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> republished = renderHeld(s, blockA, &same, 20, 60);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 60);
CHECK(republished == none);
CHECK(peakOf(none, 0, none.size()) > 0.9); // the contour really is being read
}
// The drain slot: a displaced snapshot and the live one both observe the instance's ONE block,
// so a note ringing out of either follows the edit.
static void testTwoEnginesSharingOneBlockBothFollow() {
SampleData a = dcSample(200000);
a.play.ampSpline.mode = EnvMode::Spline;
a.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
SampleData b = a;
LiveContourBlock block;
a.liveContours = &block;
b.liveContours = &block;
block.publish(foldContours(a.play));
VoiceEngine live(1, a), drain(1, b);
live.noteOn(kRoot, 127);
drain.noteOn(kRoot, 127);
std::vector<AudioSample> outLive, outDrain;
for (int i = 0; i < 2; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
PlayParams edited = a.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
for (int i = 0; i < 8; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
CHECK(std::fabs(outLive.back() - 0.25) < 1e-6);
CHECK(std::fabs(outDrain.back() - 0.25) < 1e-6);
}
// --- Allocation ----------------------------------------------------------------------------
// The counter itself must be live, or the zero below would prove nothing.
static void testTheAllocationCounterSeesAnAllocation() {
g_allocations.store(0);
g_countAllocations.store(true);
std::vector<float>* v = new std::vector<float>(16);
g_countAllocations.store(false);
delete v;
CHECK(g_allocations.load() >= 2); // the vector object and its buffer
}
// Every audio-side step of a contour swap — the block-boundary read, the rebind of every
// sounding voice on all three EGs, a note struck in the swap block, and the renders around them
// — runs with the counter armed. Only the UI thread's publish is outside it (it allocates
// nothing either, but it is not the audio side).
static void testTheAudioSideOfAContourSwapAllocatesNothing() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 0.0; // bound, so rebound, but transposing nothing
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 1.0f;
s.play.filter.modAmount = 0.0;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
edited.pitchSpline.contour = flatAt3(0.0, CurveDomain::Bipolar);
edited.filterSpline.contour = flatAt3(0.5, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(4, s);
std::vector<AudioSample> buf(kBlock, 0.0f);
const auto renderBlock = [&] {
std::fill(buf.begin(), buf.end(), 0.0f);
engine.render(buf.data(), kBlock);
};
g_allocations.store(0);
g_countAllocations.store(true);
engine.noteOn(kRoot, 127);
for (int b = 0; b < 4; ++b) renderBlock();
g_countAllocations.store(false);
block.publish(foldContours(edited)); // UI thread
g_countAllocations.store(true);
engine.noteOn(kRoot, 127); // startVoice's rebind + snap
renderBlock(); // the block boundary's read finds nothing newer: already taken
g_countAllocations.store(false);
const double afterEdit = buf[kBlock - 1];
block.publish(foldContours(s.play)); // and back, so the render path does the read itself
g_countAllocations.store(true);
for (int b = 0; b < 8; ++b) renderBlock();
g_countAllocations.store(false);
CHECK(g_allocations.load() == 0);
// Not vacuous: both swaps really landed inside the counted window, through a wide-open
// low-pass whose DC gain is unity. Two voices on the edited 0.25 contour by the end of the
// swap block (2.0 had it not landed), then both back on 1.0 through the render path's read.
CHECK(std::fabs(afterEdit - 0.5) < 1e-3);
CHECK(std::fabs(buf[kBlock - 1] - 2.0) < 1e-3);
}
int main() {
testTheFoldCarriesEveryPointAndItsHardFlag();
testAHeldNoteFollowsAnAmpContourEditWithoutAStep();
testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne();
testANoteStruckAfterAnEditPlaysItFromItsFirstFrame();
testAHeldNoteFollowsAPitchContourEdit();
testAHeldNoteFollowsAFilterContourEdit();
testAStagedEnvelopeIgnoresContourPublishes();
testRepublishingAnUnchangedContourIsByteIdentical();
testTwoEnginesSharingOneBlockBothFollow();
testTheAllocationCounterSeesAnAllocation();
testTheAudioSideOfAContourSwapAllocatesNothing();
if (g_fail == 0) std::printf("live_contours tests passed\n");
return g_fail == 0 ? 0 : 1;
}