Files
reasampler/tests/test_sampler_core.cpp
T
daniel 080a7be8ca fix(voice): CC 120 hard-stops voices (incl. Trigger); Mono sizes voices_ to 1; comments corrected
CC 120 -> allSoundsOff/hardStop (immediate silence, stops Trigger one-shots); CC 123 -> allNotesOff/releaseAll (release, unchanged). Mono VoiceEngine sizes voices_ to 1 structurally. Legato same-note re-press edge documented. Three new tests.
2026-07-27 22:08:56 -04:00

2047 lines
94 KiB
C++

// Standalone tests for reasampler::sampler_core — no VST3, no REAPER, no test
// framework. Same fast build/run loop as bank_model_tests / peaks_tests: feed known
// inputs, assert the engine's behavior.
//
// Covers (PLAN.md S3 / CONTEXT.md §Phase S pure core):
// 1. polyphonic allocation — N notes -> N voices; note-off releases the right voice.
// 2. voice stealing at the bound — deterministic policy (release-first, then oldest).
// 3. ADSR envelope shape vs a known signal, incl. release-before-sustain.
// 4. repitch ratio correctness across +/-1 octave from root incl. unity, asserted on
// the observed period of a synthesized sine.
// 5. loop-point sustain — held note past sample end loops [start,end) seamlessly;
// zero-length loop and absent-loop behavior.
// 6. keymap: chromatic-from-single-root; zoned ranges with boundary notes; velocity
// -> volume; out-of-zone note -> defined no-play.
//
// The plain-data boundary (no VST3/REAPER types in the core) is enforced STRUCTURALLY
// by the CMake target linking neither SDK — this file includes only sampler_core.h +
// the standard library, which is itself the compile-time proof.
#include "../src/vst/sampler_core.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; }
constexpr double kPi = 3.14159265358979323846;
// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity
// directly (DC of amplitude 1). Root at note 60 by default.
static SampleData dcSample(std::size_t frames, int rootNote = 60) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.rootNote = rootNote;
return s;
}
// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by
// measuring the played-back period.
static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) {
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 * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
}
s.rootNote = rootNote;
return s;
}
// An ADSR that stays fully open (level 1) forever while held, so voice output equals
// velocity gain — isolates allocation/repitch/loop tests from envelope shaping.
static AdsrParams flatAdsr() {
AdsrParams a;
a.attackFrames = 0;
a.decayFrames = 0;
a.sustainLevel = 1.0;
a.releaseFrames = 0; // note-off -> instant silence.
return a;
}
// ---------------------------------------------------------------------------
// 6. Keymap resolution.
// ---------------------------------------------------------------------------
static void testChromaticSingleRoot() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
CHECK(km.zones.size() == 1);
// Every note in 0..127 resolves to the single zone.
for (int n = 0; n <= 127; ++n) {
ZoneResolution r = km.resolve(n, 100);
CHECK(r.matched);
CHECK(r.zoneIndex == 0);
}
}
static void testZonedRangesBoundaries() {
Keymap km;
km.samples.push_back(dcSample(100, 48)); // low sample
km.samples.push_back(dcSample(100, 72)); // high sample
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
// correct zone; a first-match order test would catch an off-by-one.
km.zones.push_back(KeyZone{36, 59, 48, 0});
km.zones.push_back(KeyZone{60, 83, 72, 1});
CHECK(km.resolve(36, 100).matched);
CHECK(km.resolve(36, 100).zoneIndex == 0);
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0
CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1
CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1
// Out of every zone -> defined no-play (not a match, not zone 0).
CHECK(!km.resolve(35, 100).matched);
CHECK(!km.resolve(84, 100).matched);
CHECK(!km.resolve(127, 100).matched);
}
static void testFirstMatchOnOverlap() {
// Overlapping zones: the earlier zone wins (documented deterministic rule).
Keymap km;
km.samples.push_back(dcSample(10, 60));
km.samples.push_back(dcSample(10, 60));
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all
CHECK(km.resolve(60, 100).zoneIndex == 0);
}
// ---------------------------------------------------------------------------
// 4. Repitch ratio correctness.
// ---------------------------------------------------------------------------
static void testPitchRatioMath() {
CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9)); // unity at root
CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9)); // +1 octave
CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9)); // -1 octave
CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone
}
// --- S-VIEW-6 key-tracking ratio math (pure), asserted at 0 / 100 / 200% + off-root. ---
static void testKeyTrackedRatioMath() {
// 100% (keyTrack == 1.0) is standard 12-tone-ET and BIT-IDENTICAL to pitchRatio: the argument
// to std::pow is (note-root)*1.0, exact in IEEE-754, so the same call yields the same bits.
for (int note = 0; note <= 127; ++note) {
CHECK(keyTrackedRatio(note, 60, 1.0) == pitchRatio(note, 60)); // exact equality, not approx
}
CHECK(approx(keyTrackedRatio(72, 60, 1.0), 2.0, 1e-9)); // +1 octave tracked normally
CHECK(approx(keyTrackedRatio(48, 60, 1.0), 0.5, 1e-9)); // -1 octave tracked normally
// 0% (keyTrack == 0.0): no tracking. Every key — including off-root ones — plays root pitch.
CHECK(approx(keyTrackedRatio(60, 60, 0.0), 1.0, 1e-12)); // at root: unity (trivially)
CHECK(approx(keyTrackedRatio(72, 60, 0.0), 1.0, 1e-12)); // an octave up STILL plays root pitch
CHECK(approx(keyTrackedRatio(48, 60, 0.0), 1.0, 1e-12)); // an octave down STILL plays root pitch
CHECK(approx(keyTrackedRatio(67, 60, 0.0), 1.0, 1e-12)); // an off-root 5th STILL plays root pitch
// 200% (keyTrack == 2.0): double-rate tracking. The semitone offset is doubled, so a +12 key
// plays as if +24 (two octaves, ratio 4.0), a -12 key as -24 (ratio 0.25).
CHECK(approx(keyTrackedRatio(72, 60, 2.0), 4.0, 1e-9)); // +12 -> +24 semis -> 4.0
CHECK(approx(keyTrackedRatio(48, 60, 2.0), 0.25, 1e-9)); // -12 -> -24 semis -> 0.25
CHECK(approx(keyTrackedRatio(60, 60, 2.0), 1.0, 1e-12)); // root is unity at ANY keyTrack
// Off-root at 200% for a single semitone: +1 semi -> +2 semis -> 2^(2/12).
CHECK(approx(keyTrackedRatio(61, 60, 2.0), std::pow(2.0, 2.0 / 12.0), 1e-9));
// An arbitrary intermediate scalar (50%): +12 key tracks as +6 semis -> 2^(6/12) = sqrt(2).
CHECK(approx(keyTrackedRatio(72, 60, 0.5), std::pow(2.0, 6.0 / 12.0), 1e-9));
}
// Observe repitch on the rendered signal: a voice played an octave above root should
// advance through the sample twice as fast, so a sine's observed period halves. We
// measure the period by counting the interval between positive-going zero crossings.
static double observedPeriodFrames(const std::vector<AudioSample>& out) {
std::vector<std::size_t> upCrossings;
for (std::size_t i = 1; i < out.size(); ++i) {
if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i);
}
if (upCrossings.size() < 2) return 0.0;
// Average spacing between crossings.
double sum = 0.0;
for (std::size_t i = 1; i < upCrossings.size(); ++i) {
sum += static_cast<double>(upCrossings[i] - upCrossings[i - 1]);
}
return sum / static_cast<double>(upCrossings.size() - 1);
}
static void testRepitchObservedPeriod() {
// A sine of 20 cycles over 8000 frames -> native period 400 frames at unity.
const std::size_t frames = 8000;
const double cycles = 20.0;
const double nativePeriod = static_cast<double>(frames) / cycles; // 400
// Unity: played at root, observed period ~= native.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
double p = observedPeriodFrames(out);
CHECK(approx(p, nativePeriod, 2.0));
}
// +1 octave: advances 2x, observed period halves.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(72, 127);
std::vector<AudioSample> out;
eng.render(out, frames / 2); // half as many frames covers the whole sample
double p = observedPeriodFrames(out);
CHECK(approx(p, nativePeriod / 2.0, 2.0));
}
// -1 octave: advances 0.5x, observed period doubles.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(48, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
double p = observedPeriodFrames(out);
CHECK(approx(p, nativePeriod * 2.0, 4.0));
}
}
// --- S-VIEW-6 keyTrack reaches the VARISPEED engine: observed period tracks the scalar. ---
static void testKeyTrackVarispeedObservedPeriod() {
const std::size_t frames = 8000;
const double cycles = 20.0;
const double nativePeriod = static_cast<double>(frames) / cycles; // 400 at unity
auto periodAt = [&](int note, double keyTrack) -> double {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
// The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
km.zones[0].keyTrack = keyTrack;
VoiceEngine eng(4, km);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
return observedPeriodFrames(out);
};
// note 72 (+1 octave). At 100% it plays an octave up (period halves ~200). At 0% it plays at
// ROOT pitch (period ~native 400 — no tracking). The observed periods must differ by ~2x, which
// proves the scalar drove the Varispeed read rate.
const double at100 = periodAt(72, 1.0);
const double at0 = periodAt(72, 0.0);
CHECK(approx(at100, nativePeriod / 2.0, 3.0)); // 100%: tracked an octave up
CHECK(approx(at0, nativePeriod, 3.0)); // 0%: no tracking, plays root pitch
}
// --- S-VIEW-6 keyTrack reaches the PRESERVE engine: at 0% an off-root note collapses to the root
// shift (unity), producing output identical to playing the root note. Proves keyTrack feeds
// baseRatio_ -> the Preserve shift amount (not merely the Varispeed read rate). ---
static void testKeyTrackPreserveShiftCollapsesAtZero() {
const std::size_t frames = 2000;
const std::size_t window = 512;
const double cycles = 40.0;
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].keyTrack = keyTrack;
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
return out;
};
// An off-root note (+7) at keyTrack 0.0 sets the Preserve shift to the root ratio (1.0) — the
// shifter is pass-through, so the output must be BIT-IDENTICAL to playing the ROOT note (whose
// offset is 0, also shift 1.0). If keyTrack only touched Varispeed, these would differ.
const std::vector<AudioSample> offRootNoTrack = renderPreserve(67, 0.0);
const std::vector<AudioSample> rootRef = renderPreserve(60, 1.0);
CHECK(offRootNoTrack.size() == rootRef.size());
bool identical = offRootNoTrack.size() == rootRef.size();
for (std::size_t i = 0; i < offRootNoTrack.size() && identical; ++i) {
if (offRootNoTrack[i] != rootRef[i]) identical = false;
}
CHECK(identical); // 0% tracking collapses the Preserve shift to unity, exactly like the root
}
// ---------------------------------------------------------------------------
// 3. ADSR envelope shape vs a known signal.
// ---------------------------------------------------------------------------
static void testAdsrShape() {
AdsrParams p;
p.attackFrames = 10;
p.decayFrames = 10;
p.sustainLevel = 0.5;
p.releaseFrames = 10;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
// Attack: 0 -> ramps up. Frame 0 == 0, rising each frame.
double prev = -1.0;
for (int i = 0; i < 10; ++i) {
double v = env.tick();
CHECK(v >= prev); // monotonic non-decreasing through attack
CHECK(v >= 0.0 && v <= 1.0);
prev = v;
}
// Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing.
prev = 2.0;
for (int i = 0; i < 10; ++i) {
double v = env.tick();
CHECK(v <= prev + 1e-9); // non-increasing through decay
CHECK(v >= 0.5 - 1e-9); // never below sustain during decay
prev = v;
}
// Sustain: holds 0.5 indefinitely.
for (int i = 0; i < 100; ++i) {
CHECK(approx(env.tick(), 0.5, 1e-9));
}
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
// Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0.
env.noteOff();
prev = 1.0;
for (int i = 0; i < 10; ++i) {
double v = env.tick();
CHECK(v <= prev + 1e-9); // non-increasing through release
prev = v;
}
CHECK(env.finished());
for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12));
}
static void testAdsrReleaseBeforeSustain() {
// noteOff during the attack ramp releases from the PARTIAL level, not sustain.
AdsrParams p;
p.attackFrames = 100;
p.decayFrames = 10;
p.sustainLevel = 0.8;
p.releaseFrames = 20;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
// Advance 50 frames into a 100-frame attack -> partial level ~0.5.
double last = 0.0;
for (int i = 0; i < 50; ++i) last = env.tick();
CHECK(last > 0.3 && last < 0.7); // partway up the attack ramp
CHECK(env.stage() == AdsrEnvelope::Stage::Attack);
env.noteOff();
CHECK(env.stage() == AdsrEnvelope::Stage::Release);
// First release frame must be at or below the partial level we left off at —
// NOT jump up to sustain 0.8. This is the release-before-sustain guarantee.
double firstRelease = env.tick();
CHECK(firstRelease <= last + 1e-9);
CHECK(firstRelease < p.sustainLevel); // proves it did not snap to sustain
// Decays to zero.
double prev = firstRelease;
for (int i = 0; i < 20; ++i) {
double v = env.tick();
CHECK(v <= prev + 1e-9);
prev = v;
}
CHECK(env.finished());
}
static void testAdsrZeroAttackDecay() {
// Zero attack + zero decay -> jumps straight to sustain on the first ticks.
AdsrParams p;
p.attackFrames = 0;
p.decayFrames = 0;
p.sustainLevel = 0.7;
p.releaseFrames = 5;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
// Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions
// through the (also zero-length) decay, so by frame 1 the envelope is holding
// sustain. The peak-at-boundary is the documented single-frame edge, not a bug.
CHECK(approx(env.tick(), 1.0, 1e-9)); // frame 0: attack peak
CHECK(approx(env.tick(), 0.7, 1e-9)); // frame 1: sustain
CHECK(approx(env.tick(), 0.7, 1e-9));
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
}
// ---------------------------------------------------------------------------
// 1. Polyphonic allocation + note-off routing.
// ---------------------------------------------------------------------------
static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice.
std::size_t v60 = eng.noteOn(60, 100);
std::size_t v64 = eng.noteOn(64, 100);
std::size_t v67 = eng.noteOn(67, 100);
std::size_t v72 = eng.noteOn(72, 100);
CHECK(v60 != VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 4);
CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72);
// Note-off on 64 releases exactly one voice; with instant release it goes idle
// after the next render frame.
eng.noteOff(64);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(eng.activeVoiceCount() == 3);
// The still-held notes keep sounding.
eng.render(out, 1);
CHECK(eng.activeVoiceCount() == 3);
}
static void testNoteOffReleasesNewestSameNote() {
// Prove that noteOff releases the NEWEST (highest startOrder) instance of a
// re-triggered note, leaving the older voice in sustain.
//
// Two voices at distinct velocities so their output is distinguishable:
// "first" (older) -> velocity 64 -> gain ~0.504 (G_old)
// "second" (newer) -> velocity 127 -> gain 1.0 (G_new)
//
// With a DC-1 sample and sustain=1, while both are held:
// render sum == G_old + G_new.
//
// After noteOff (must release newest), the newer voice enters a short release.
// Render past releaseFrames: newer voice finishes; only the older voice remains.
// Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice
// were released, the older would finish and the remaining sum would equal G_new
// (1.0 vs ~0.504) — the velocities make the error distinguishable.
const int velOld = 64;
const int velNew = 127;
const double gainOld = velOld / 127.0; // ~0.504
const double gainNew = velNew / 127.0; // 1.0
SampleData sd = dcSample(100000, 60);
sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(sd);
// A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default
// flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this
// note-off-selection test relies on — so we opt this zone back to the linear response.
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
CHECK(first != second);
CHECK(eng.activeVoiceCount() == 2);
// While both are held, combined output equals gainOld + gainNew.
{
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], gainOld + gainNew, 1e-4));
}
// Release once — must target the NEWEST voice (second).
eng.noteOff(60);
// Render past the release (releaseFrames == 10): newer voice goes Finished.
std::vector<AudioSample> out;
eng.render(out, 20);
// Newer voice must be done; only the older voice remains.
CHECK(eng.activeVoiceCount() == 1);
// Tail frames must equal gainOld (~0.504), NOT gainNew (1.0).
// If the older voice were released instead, the tail would be ~1.0 here.
for (std::size_t i = 15; i < out.size(); ++i) {
CHECK(approx(out[i], gainOld, 1e-4));
}
// A second note-off releases the remaining older voice.
eng.noteOff(60);
eng.render(out, 20);
CHECK(eng.activeVoiceCount() == 0);
}
static void testOutOfZoneNoteConsumesNoVoice() {
Keymap km;
km.samples.push_back(dcSample(100, 60));
km.zones.push_back(KeyZone{60, 72, 60, 0});
VoiceEngine eng(4, km);
std::size_t v = eng.noteOn(30, 100); // below the only zone
CHECK(v == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0); // no voice consumed
}
// ---------------------------------------------------------------------------
// 2. Voice stealing at the bound.
// ---------------------------------------------------------------------------
static void testStealsReleasingVoiceFirst() {
// Long per-zone release so the voice stays active through the release tail. Voice::start reads
// sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
CHECK(eng.activeVoiceCount() == 2);
// Release the NEWER voice (62) — it becomes the only releasing voice.
eng.noteOff(62);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(eng.activeVoiceCount() == 2); // both still ringing (long release)
// A new note with the pool full must steal the RELEASING voice (vB), not the
// older held voice (vA) — release-first policy.
std::size_t vC = eng.noteOn(64, 100);
CHECK(vC == vB);
CHECK(eng.activeVoiceCount() == 2);
}
static void testStealsOldestWhenNoneReleasing() {
// Long per-zone release — placed on SampleData.play.adsr per the S12 fix.
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
CHECK(vA != vB);
// No voice released; both held. A new note steals the OLDEST (vA).
std::size_t vC = eng.noteOn(64, 100);
CHECK(vC == vA);
CHECK(eng.activeVoiceCount() == 2);
// The stolen voice now carries note 64; a note-off on 60 (the stolen-away note)
// finds nothing to release.
std::size_t before = eng.activeVoiceCount();
eng.noteOff(60);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(eng.activeVoiceCount() == before); // 60 no longer exists; no-op
}
// ---------------------------------------------------------------------------
// 5. Loop-point-aware sustain.
// ---------------------------------------------------------------------------
static void testLoopSustainSeamless() {
// A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop
// region of value 0.5. Held far past the sample end, the voice must keep emitting
// the loop region (0.5) rather than going silent.
SampleData s;
s.frames.resize(40);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) / 20.0f; // attack
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
s.rootNote = 60;
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
eng.render(out, 200); // 5x the sample length
// Voice is still active (looping), not exhausted.
CHECK(eng.activeVoiceCount() == 1);
// Frames well past the loop start must sit at the loop body value 0.5.
for (std::size_t i = 60; i < out.size(); ++i) {
CHECK(approx(out[i], 0.5, 1e-4));
}
}
static void testZeroLengthLoopGoesSilent() {
// A zero-length loop (start == end) is the "no sustain" marker: the note runs off
// the sample end and the voice goes idle, rather than spinning on an empty span.
SampleData s = dcSample(50, 60); // 50 frames of 1.0
s.loop.hasLoop = true;
s.loop.start = 25;
s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 100); // past the 50-frame end
// After frame ~50 the voice should have gone idle (no loop to sustain it).
CHECK(eng.activeVoiceCount() == 0);
// Tail frames are silent.
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
static void testSingleFrameLoop() {
// A loop of exactly one frame [start, start+1) — the narrowest valid loop.
// The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer
// readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing.
SampleData s;
s.frames.resize(10);
for (int i = 0; i < 10; ++i) s.frames[i] = static_cast<float>(i) * 0.1f;
s.rootNote = 60;
s.loop.hasLoop = true;
s.loop.start = 5;
s.loop.end = 6; // single-frame loop: [5, 6)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
eng.render(out, 50); // well past the sample end
// Voice must still be active — the single-frame loop keeps it alive.
CHECK(eng.activeVoiceCount() == 1);
// Every frame from the loop-start onward must be the value of frame 5 (0.5).
for (std::size_t i = 10; i < out.size(); ++i) {
CHECK(approx(out[i], 0.5, 1e-4));
}
}
static void testAbsentLoopGoesSilent() {
// No loop at all: held note runs off the end and goes idle (same as zero-length).
SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 100);
CHECK(eng.activeVoiceCount() == 0);
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
// ---------------------------------------------------------------------------
// start point (S11): the voice's initial read position is SampleData::startFrame.
// ---------------------------------------------------------------------------
static void testStartFrameOffsetsInitialRead() {
// A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the
// read position. startFrame = 30 -> the first output frame reads frame 30 (0.30).
SampleData s;
s.frames.resize(100);
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
eng.render(out, 3);
CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0
CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio
CHECK(approx(out[2], 0.32, 1e-4));
}
static void testStartFrameZeroIsUnchanged() {
// startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0.
SampleData s;
s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-6)); // frame 0
}
static void testStartFrameOutOfRangeClampsToZero() {
// A start point at/past the sample end degrades to frame 0 (play from the top), never an
// out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
// Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle.
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
static void testStartFrameWithLoop() {
// Start point and loop compose: begin reading mid-sample, then sustain the loop region.
SampleData s;
s.frames.resize(40);
for (int i = 0; i < 40; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
s.rootNote = 60;
s.startFrame = 10; // begin at frame 10
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10
CHECK(eng.activeVoiceCount() == 1); // loop sustains it
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4));
}
static void testStartAfterLoopEndWrapsIntoLoop() {
// Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount),
// the voice's initial read head is past the loop end. The wrap-while in renderFrame must
// pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from
// somewhere inside the loop rather than running off the sample end silently.
//
// Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40).
// loop body is a constant 0.5 so every frame inside it reads 0.5.
// After wrap: readPos starts inside [20, 40), first output frame == 0.5.
// Voice must stay active (loop sustains it) and emit the loop value, NOT go silent.
SampleData s;
s.frames.resize(100, 0.0f);
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
s.rootNote = 60;
s.startFrame = 60; // > loop.end (40), < frameCount (100)
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
eng.render(out, 50);
// Voice must still be active — the usable loop keeps it alive indefinitely.
CHECK(eng.activeVoiceCount() == 1);
// Every frame after the first wrap must read 0.5 (the loop body). We skip the very
// first frame because the fractional-position wrap lands somewhere in [20,40) and the
// exact offset depends on how many loop lengths fit into 60; what matters is that the
// voice is alive and emitting the loop value, not 0.0 (pre-loop region).
for (std::size_t i = 5; i < out.size(); ++i) {
CHECK(approx(out[i], 0.5, 1e-4));
}
}
// ---------------------------------------------------------------------------
// velocity -> volume.
// ---------------------------------------------------------------------------
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone is now flat
// y=1, so EVERY velocity plays at unity — NOT the old linear velocity/127. singleSampleChromatic
// builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
static void testVelocityDefaultCurveIsFlatUnity() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0, flat default curve
for (int vel : {1, 64, 100, 127}) {
VoiceEngine eng(1, km);
eng.noteOn(60, vel);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4)); // flat y=1: any velocity -> unity gain
}
}
// A LINEAR curve on the zone reproduces the pre-r10 velocity/127 ramp exactly — proving the curve
// (not a hardcoded map) drives the gain, and that eval is applied at note-on.
static void testVelocityLinearCurveReproducesRamp() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
{
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
{
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
}
{
VoiceEngine eng(1, km);
eng.noteOn(60, 1);
std::vector<AudioSample> out; eng.render(out, 1);
CHECK(approx(out[0], 1.0 / 127.0, 1e-4));
}
}
// A shaped curve (a single interior knot) drives the gain through eval — a mid velocity reads the
// curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
static void testVelocityShapedCurveDrivesGain() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
vst::VelocityCurve curve = vst::VelocityCurve::linear();
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
km.zones[0].velocityCurve = curve;
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1);
// At exactly velocity 64 the curve passes through the knot -> gain 0.9 (well above the linear
// 64/127 ~= 0.504), so the rendered DC value is the shaped 0.9.
CHECK(approx(out[0], 0.9, 1e-4));
}
// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum
}
// ---------------------------------------------------------------------------
// 7. Stereo channel dimension (S7).
// ---------------------------------------------------------------------------
// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render
// must keep them distinct; a mono render (channel 0 only) sees L.
static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) {
SampleData s;
s.frames.assign(frames, l);
s.framesR.assign(frames, r);
s.rootNote = rootNote;
return s;
}
static void testChannelCount() {
// Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2.
CHECK(dcSample(10, 60).channelCount() == 1);
CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2);
// A mismatched framesR length is treated as mono (a bad pair never half-plays).
SampleData bad = dcSample(10, 60);
bad.framesR.assign(5, 0.5f); // wrong length
CHECK(bad.channelCount() == 1);
}
static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8);
for (std::size_t i = 0; i < 8; ++i) {
CHECK(approx(left[i], 1.0, 1e-4)); // channel 0
CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L
}
}
static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8);
for (std::size_t i = 0; i < 8; ++i) {
CHECK(approx(left[i], 1.0, 1e-4));
CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0
}
}
static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS);
engS.noteOn(60, 127);
std::vector<AudioSample> mono;
engS.render(mono, 8); // the mono overload
for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R
}
static void testStereoRenderAdvancesLikeMonoRepitch() {
// The stereo path must advance the read head by the SAME per-frame ratio as the mono path,
// so repitch is identical. Play a stereo sine (both channels the same signal) an octave up
// and confirm the observed period halves — the mono repitch assertion, on the stereo path.
const std::size_t frames = 8000;
const double cycles = 20.0;
const double nativePeriod = static_cast<double>(frames) / cycles; // 400
SampleData s;
s.frames.resize(frames);
s.framesR.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
const float v = static_cast<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(frames)));
s.frames[i] = v;
s.framesR[i] = v;
}
s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
eng.render(left.data(), right.data(), frames / 2);
CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0));
CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically
}
static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
eng.render(left.data(), right.data(), 1);
CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5
CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5
}
static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f);
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
eng.render(buf.data(), nullptr, 4); // null right -> no-op
for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched
}
static void testStereoStartFrameLoopShareOneReadHead() {
// S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH
// channels from the SAME single read head — one offset, one loop wrap, applied to L and R
// identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset
// apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave
// L and R independent heads, the constant L->R offset would break at the start jump or the
// loop seam.
SampleData s;
s.frames.resize(40);
s.framesR.resize(40);
for (int i = 0; i < 40; ++i) {
s.frames[i] = static_cast<float>(i) * 0.01f; // L: 0.00 .. 0.39
s.framesR[i] = static_cast<float>(i) * 0.01f + 0.5f; // R: L + 0.5, everywhere
}
s.rootNote = 60;
s.startFrame = 10; // begin BOTH channels at frame 10
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
eng.render(left.data(), right.data(), 200);
// First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset.
CHECK(approx(left[0], 0.10, 1e-4));
CHECK(approx(right[0], 0.60, 1e-4));
// The loop sustains the voice indefinitely.
CHECK(eng.activeVoiceCount() == 1);
// At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index
// (one read head) through the start jump and every loop wrap. A per-channel head drift would
// break this invariant at the seam.
for (std::size_t i = 0; i < left.size(); ++i) {
CHECK(approx(right[i] - left[i], 0.5, 1e-4));
}
// Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio
// frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the
// loop region on both channels, never running off the sample end.
for (std::size_t i = 15; i < left.size(); ++i) {
CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4);
}
}
// ===========================================================================
// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity).
// ===========================================================================
// --- AHDSR hold stage vs a known signal. ---
static void testAhdsrHoldStageShape() {
// Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f),
// decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames.
AdsrParams p;
p.attackFrames = 5;
p.holdFrames = 8;
p.decayFrames = 5;
p.sustainLevel = 0.5;
p.releaseFrames = 5;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
for (int i = 0; i < 5; ++i) env.tick(); // consume Attack (ends at 1.0)
// The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold.
for (int i = 0; i < 8; ++i) {
CHECK(env.stage() == AdsrEnvelope::Stage::Hold);
CHECK(approx(env.tick(), 1.0, 1e-9));
}
// Then Decay begins, falling from 1.0 toward sustain 0.5.
CHECK(env.stage() == AdsrEnvelope::Stage::Decay);
double v = env.tick();
CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9);
}
// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). ---
static void testAhdsrHoldZeroEqualsAdsr() {
// The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame.
// Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be
// tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output:
// Attack: 0/4, 1/4, 2/4, 3/4 (ticks 0..3, level rising 0 -> 0.75)
// Decay: 1.0, then 1.0+(0.5-1)*t for t=1/4..3/4 (ticks 4..7: 1.0, 0.875, 0.75, 0.625)
// Sustain: 0.5 forever (tick 8+)
AdsrParams p;
p.attackFrames = 4;
p.holdFrames = 0; // the degenerate — must NOT insert an extra unity frame
p.decayFrames = 4;
p.sustainLevel = 0.5;
p.releaseFrames = 4;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
const double expected[] = {0.0, 0.25, 0.5, 0.75, // attack
1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0)
0.5, 0.5, 0.5}; // sustain
for (double e : expected) CHECK(approx(env.tick(), e, 1e-9));
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); // reached sustain at the SAME tick count
}
// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
static SampleData triggerSample(std::size_t frames, double lengthFraction,
std::int64_t fadeIn, std::int64_t fadeOut,
std::int64_t startFrame = 0) {
SampleData s = dcSample(frames, 60);
s.startFrame = startFrame;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch
s.play.trigger.lengthFraction = lengthFraction;
s.play.trigger.fadeInFrames = fadeIn;
s.play.trigger.fadeOutFrames = fadeOut;
return s;
}
// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out;
eng.render(out, 200);
// First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed.
for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f);
for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0); // ran off playEnd
}
// --- Trigger start point: %-length measured from the start offset. ---
static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f);
for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0);
}
// --- Trigger fade-in / fade-out ramp shape (equal-power default). ---
static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 120);
CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0
// Fade-in monotonic non-decreasing.
for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4);
// Unity plateau in the middle.
for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3));
// Fade-out monotonic non-increasing over [80,100).
for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4);
// Past playEnd = silence.
for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). ---
static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
for (float v : out) CHECK(approx(v, 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0);
}
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
{
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4);
CHECK(eng.activeVoiceCount() == 0);
}
// %=100 plays the full post-start span.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f);
for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
}
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 10);
eng.noteOff(60); // must be a NO-OP in Trigger
CHECK(eng.activeVoiceCount() == 1); // still sounding after note-off
eng.render(out, 200);
// It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off).
for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f);
for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0); // frees on its own playEnd, not on note-off
}
// ===========================================================================
// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical).
// ===========================================================================
// Render one note to completion (or `maxFrames`) and return the frame count at which the voice
// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off.
static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) {
std::vector<AudioSample> out;
std::size_t len = 0;
for (std::size_t f = 0; f < maxFrames; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() > 0) len = f + 1;
else break;
}
return len;
}
// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable
// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from
// Gate's release tail.
static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) {
SampleData s = dcSample(frames, 60);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = lengthFraction;
return s;
}
// --- Preserve duration invariance: same note length across +/-12 semitones. ---
static void testPreserveDurationInvariance() {
// A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames
// regardless of transpose (duration held). Under Varispeed an octave up would halve it.
const std::size_t frames = 1000;
const std::size_t window = 512; // pre-size the shifters
auto lengthAt = [&](int note) -> std::size_t {
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
};
const std::size_t atRoot = lengthAt(60);
const std::size_t atUp = lengthAt(72); // +12
const std::size_t atDown = lengthAt(48); // -12
// All three within a small tolerance of the source length (Preserve holds duration). The
// tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
CHECK(atUp >= frames - 20 && atUp <= frames + 20);
CHECK(atDown >= frames - 20 && atDown <= frames + 20);
// The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would)
CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
}
// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). ---
static void testVarispeedStillCouplesDuration() {
// A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled).
auto lengthAt = [&](int note) -> std::size_t {
SampleData s = dcSample(1000, 60);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
};
const std::size_t atRoot = lengthAt(60);
const std::size_t atUp = lengthAt(72);
CHECK(approx(static_cast<double>(atUp), static_cast<double>(atRoot) / 2.0, 30.0));
}
// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). ---
static void testPitchEnvOffBitIdentical() {
// Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their
// rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16
// "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift.
const std::size_t n = 4000;
auto renderOne = [&](bool withDisabledEnv) -> std::vector<AudioSample> {
SampleData s = sineSample(n, 20.0, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
if (withDisabledEnv) {
s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0)
s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
}
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out;
eng.render(out, n);
return out;
};
const std::vector<AudioSample> a = renderOne(false);
const std::vector<AudioSample> b = renderOne(true);
CHECK(a.size() == b.size());
bool identical = a.size() == b.size();
for (std::size_t i = 0; i < a.size() && identical; ++i) {
if (a[i] != b[i]) identical = false;
}
CHECK(identical); // disabled pitch env produces the EXACT same samples (no modulation)
}
// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. ---
static void testPitchEnvOnBendsVarispeed() {
// Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and
// settles. Observe the read advancing FASTER at the start (period shorter early) than late.
const std::size_t n = 8000;
SampleData s = sineSample(n, 40.0, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out;
eng.render(out, 4000);
// Early period (heavily transposed up) should be shorter than the late period (settled).
std::vector<AudioSample> early(out.begin(), out.begin() + 800);
std::vector<AudioSample> late(out.begin() + 3200, out.begin() + 4000);
const double pe = observedPeriodFrames(early);
const double pl = observedPeriodFrames(late);
CHECK(pe > 0.0 && pl > 0.0);
CHECK(pe < pl); // pitch dropped over time (period lengthened) -> the AD env bent the pitch
}
// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). ---
static void testPreserveGateStereoLoopComposes() {
// A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH
// channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing.
SampleData s;
const std::size_t frames = 400;
s.frames.resize(frames);
s.framesR.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
const float v = static_cast<float>(std::sin(2.0 * kPi * 8.0 *
static_cast<double>(i) / static_cast<double>(frames)));
s.frames[i] = v;
s.framesR[i] = v * 0.5f; // R is a distinct (half-amplitude) channel
}
s.rootNote = 60;
s.loop.hasLoop = true;
s.loop.start = 100;
s.loop.end = 300;
s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
eng.render(left.data(), right.data(), 2000);
// The loop sustains the voice well past the sample length (400 frames) -> still active.
CHECK(eng.activeVoiceCount() == 1);
// Both channels carry signal (some frame has non-trivial magnitude on each).
double maxL = 0.0, maxR = 0.0;
for (std::size_t i = 600; i < 2000; ++i) {
if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]);
if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]);
}
CHECK(maxL > 0.05);
CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved
}
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
// Since the Phase S re-scope EVERY engine Preserve voice (root included) runs the shifter and
// counts toward the cap — the unity demotion is preview-card-only (see the Phase S section).
static void testPreserveVoiceCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
// 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
CHECK(eng.activeVoiceCount() == 2);
}
// ---------------------------------------------------------------------------
// FA1 (re-scoped by Phase S) — the Preserve unity-Varispeed bypass now belongs to the PREVIEW
// CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note so a chromatic line has
// one uniform onset (the FA1-review ~25 ms root-note timing-step finding); the card — always
// fired at the effective root, latency-critical, with no line to be uneven against — opts in
// and speaks on frame one.
// ---------------------------------------------------------------------------
// The ENGINE'S root-note Preserve voice now keeps the OLA path: frame 0 is the shifter's fill
// (near-silent), full level once the ring fills — the SAME onset as its transposed neighbors.
// Pre-re-scope this voice was demoted and spoke at 1.0 on frame 0.
static void testPreserveUnityEngineVoiceKeepsUniformOnset() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(60, 127); // at root: unity shift — NO demotion in the MIDI engine
std::vector<AudioSample> out;
eng.render(out, 1500);
double early = 0.0;
for (std::size_t i = 0; i < 8; ++i) {
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
}
CHECK(early < 0.1); // shifter onset, exactly like a transposed note
double late = 0.0;
for (std::size_t i = 600; i < 1500; ++i) {
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
}
CHECK(late > 0.9); // and the ring fills to full level
}
// The PREVIEW CARD at unity speaks on frame ONE — the FA1 latency fix, now scoped to the card.
static void testPreviewCardUnitySpeaksImmediately() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km, /*preserveWindowFrames=*/512);
card.noteOn(60, 127); // at root: unity shift -> demoted inside the card, zero onset delay
std::vector<AudioSample> buf(4, 0.0f);
card.render(buf.data(), buf.size());
CHECK(approx(buf[0], 1.0, 1e-6)); // the DC sample, on the very first frame
}
// keyTrack 0 collapses EVERY note to unity — an off-root preview also demotes, speaks at once.
static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity)
PreviewCard card(km, /*preserveWindowFrames=*/512);
card.noteOn(67, 127);
std::vector<AudioSample> buf(4, 0.0f);
card.render(buf.data(), buf.size());
CHECK(approx(buf[0], 1.0, 1e-6));
}
// A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY.
static void testPreviewCardTransposedKeepsShifter() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km, /*preserveWindowFrames=*/512);
card.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
std::vector<AudioSample> buf(8, 0.0f);
card.render(buf.data(), buf.size());
double early = 0.0;
for (std::size_t i = 0; i < 8; ++i) {
early = (std::max)(early, static_cast<double>(std::fabs(buf[i])));
}
CHECK(early < 0.1); // shifter fill — duration preservation kept for off-root previews
}
// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent
// half-window cost of preserving duration) and the voice reaches full level once the ring fills.
// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes.
static void testPreserveTransposedVoiceKeepsOlaPath() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
std::vector<AudioSample> out;
eng.render(out, 1500);
// Early frames are the shifter's fill (near-silent) — the structural OLA onset.
double early = 0.0;
for (std::size_t i = 0; i < 8; ++i) {
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
}
CHECK(early < 0.1);
// Once the ring is full of the DC source (>= window frames in), output reaches the sample
// level (Hann taps partition unity, so DC passes at gain 1).
double late = 0.0;
for (std::size_t i = 600; i < 1500; ++i) {
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
}
CHECK(late > 0.9);
}
// Phase S re-scope consequence: a ROOT-note engine Preserve voice keeps its shifter, so it
// COUNTS toward the Preserve cap like any other (pre-re-scope it was demoted and exempt).
static void testPreserveUnityVoiceCountsTowardCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
CHECK(eng.activeVoiceCount() == 2);
}
// FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the
// gain under the PRESERVE product-default engine with a CONFIGURED shifter window (every prior
// velocity test ran the bare Varispeed core). A linear y=x curve at velocity 1 must be
// near-silent — NOT max volume.
static void testVelocityCurveAppliesUnderPreserve() {
auto steadyLevelAt = [&](int vel) -> double {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
std::vector<AudioSample> out;
eng.render(out, 1000);
return static_cast<double>(out[900]); // steady state: ring is fully DC by frame 256
};
CHECK(approx(steadyLevelAt(127), 1.0, 0.02));
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0, 0.02));
CHECK(steadyLevelAt(1) < 0.02); // y=x at velocity 1: near-silent, the Daniel repro case
}
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
//
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
// 1.0 at frame 0. This is the load-bearing proof.
static void testPerZoneAdsrReachesVoiceEnvelope() {
SampleData s = dcSample(500, 60);
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
s.play.adsr.attackFrames = 10;
s.play.adsr.holdFrames = 0;
s.play.adsr.decayFrames = 0;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out;
eng.render(out, 20);
// Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
CHECK(out[9] < 1.0 - 1e-9);
// Frame 10+: attack complete, sustain at 1.0.
CHECK(approx(out[10], 1.0, 1e-9));
CHECK(approx(out[19], 1.0, 1e-9));
}
// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path.
// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must
// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the
// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before.
static void testZeroAdsrIsInstantSustain() {
SampleData s = dcSample(20, 60);
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
// All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level.
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
}
// ---------------------------------------------------------------------------
// Phase S — parameterized voice count, MONO mode (last-note held stack, Retrigger/Legato),
// and the isolated PREVIEW CARD.
// ---------------------------------------------------------------------------
// A DC sample at `level` with a flat (instant, fully-open) envelope — rendered output equals
// level * velocity gain, so WHICH sample is sounding is directly observable in the mix.
static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
SampleData s;
s.frames.assign(frames, level);
s.rootNote = rootNote;
s.play.adsr = flatAdsr();
return s;
}
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
// notes [60,80] root 70 -> 0.75.
static Keymap twoLevelKeymap() {
Keymap km;
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
km.zones.push_back(a);
km.zones.push_back(b);
return km;
}
// The rendered value on the next frame — one-frame probe of "what is sounding right now".
static double probeFrame(VoiceEngine& eng) {
std::vector<AudioSample> out;
eng.render(out, 1);
return static_cast<double>(out[0]);
}
// MONO last-note priority: a new note TAKES the single voice; releasing the top note falls
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
// ONE voice regardless of the pool size.
static void testMonoLastNotePriorityAndFallback() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // top released -> FALLBACK to still-held 50
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // last finger up -> gate off (release 0 = instant)
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
}
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
// note also leaves the stack, so the final note-off truly empties it.
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
eng.noteOff(50); // releasing the buried note: inaudible
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
}
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
// beneath becomes the fallback.
static void testMonoRepressHeldNoteMovesToTop() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127);
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // falls back to 70 (now the most recent held)
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
}
// A RETRIGGER fallback re-strikes the fallen-back-to note at ITS ORIGINAL velocity (kept per
// held note on the stack), not the departing note's.
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = vst::VelocityCurve::linear(); // gain = velocity/127
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 32); // soft first note
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
eng.noteOn(64, 127); // loud takeover
CHECK(approx(probeFrame(eng), 1.0, 1e-6));
eng.noteOff(64); // fallback re-strikes 60 at ITS velocity (32)
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
}
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
static void testMonoOutOfZoneNeverJoinsStack() {
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(70, 127);
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
eng.noteOff(20); // inert
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
}
// RETRIGGER restarts the amplitude envelope on a mono takeover: mid-attack level drops back
// to the ramp's origin when the new note takes the voice.
static void testMonoRetriggerRestartsEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50); // mid-attack: level ~0.49 at frame 49
CHECK(approx(out[49], 0.49, 1e-6));
eng.noteOn(62, 127); // takeover: envelope RESTARTS
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // back at the attack origin
}
// LEGATO keeps the envelope running through a same-sample takeover: pitch moves, NO re-attack.
static void testMonoLegatoContinuesEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
CHECK(approx(out[49], 0.49, 1e-6));
eng.noteOn(62, 127); // legato takeover: envelope KEEPS running
CHECK(approx(probeFrame(eng), 0.50, 1e-6)); // frame 50 of the SAME attack ramp
}
// LEGATO retunes without restarting the read head, and the velocity gain stays the FIRST
// note's (a legato phrase is one gesture, one strike). Observed on a ramp sample: values
// continue from the current read position at the NEW pitch ratio; a soft second strike does
// not duck the level.
static void testMonoLegatoRetunesWithoutReadRestart() {
SampleData s;
s.frames.resize(200000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(i); // ramp: output value == read position
}
s.rootNote = 60;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
std::vector<AudioSample> out;
eng.render(out, 10);
CHECK(approx(out[9], 9.0, 1e-4));
eng.noteOn(72, 1); // legato to +1 octave at a WHISPER velocity
CHECK(approx(probeFrame(eng), 10.0, 1e-3)); // read CONTINUES at 10 — no restart, gain kept
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
}
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
// sample restarts the voice (one read head cannot glide between two PCM streams).
static void testMonoLegatoCrossSampleRestarts() {
Keymap km = twoLevelKeymap();
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
}
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
// stack, so the next press is a fresh phrase, not a takeover.
static void testMonoLegatoAfterReleaseReattacks() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 150); // through the attack: at full level
eng.noteOff(60); // release begins (stack now empty)
out.clear();
eng.render(out, 10);
eng.noteOn(62, 127); // a NEW phrase: re-attacks even in Legato
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // fresh attack origin, not the ringing level
}
// MONO does not apply the S16 Preserve cap: a single voice runs at most one shifter — a
// Preserve->Preserve takeover must never be dropped by the cap.
static void testMonoIgnoresPreserveCap() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
CHECK(eng.noteOn(64, 127) == 0); // takeover NOT dropped (poly cap would drop it)
CHECK(eng.activeVoiceCount() == 1);
}
// A ramp sample (output value == read position) so a re-attack (read restarts at 0) is
// directly distinguishable from a legato retune (read continues) on the rendered value.
static SampleData rampSample(std::size_t frames, int rootNote) {
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i) s.frames[i] = static_cast<float>(i);
s.rootNote = rootNote;
s.play.adsr = flatAdsr();
return s;
}
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
// silently RETUNED it in place. The correct predicate is the HELD-STACK depth: with no other
// key down, the next note is a fresh phrase and must restart the read head.
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame
std::vector<AudioSample> out;
eng.render(out, 10);
eng.noteOff(60); // Trigger ignores the gate: keeps ringing...
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // ...read head still advancing past 10
eng.noteOn(62, 127); // NO key held -> fresh phrase: RE-ATTACK
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (a retune would read ~11)
// And it is genuinely playing from the top at the new pitch (ratio 2^(2/12) ~ 1.1225),
// not merely silent: the next frame reads at the advanced position.
CHECK(approx(probeFrame(eng), std::pow(2.0, 2.0 / 12.0), 1e-3));
}
// Companion boundary: with another key STILL physically held, a same-sample Trigger takeover
// under Legato still RETUNES (read continues) — the held-stack predicate matches the old
// behavior everywhere except the ringing-but-unheld case above.
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 10);
eng.noteOn(62, 127); // 60 still held -> legato takeover
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack
}
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
static void testAllNotesOffReleasesPolyVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
eng.noteOn(64, 127);
CHECK(eng.activeVoiceCount() == 3);
eng.allNotesOff();
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant)
CHECK(eng.activeVoiceCount() == 0);
}
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
static void testAllNotesOffClearsMonoHeldStack() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
eng.allNotesOff(); // PANIC
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
eng.noteOn(70, 127);
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
}
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
static void testPreviewCardReleaseAll() {
Keymap km = twoLevelKeymap();
PreviewCard card(km);
card.noteOn(70, 127);
CHECK(card.active());
card.releaseAll(); // no note argument: quiets whatever rings
std::vector<AudioSample> buf(1, 0.0f);
card.render(buf.data(), buf.size());
CHECK(approx(buf[0], 0.0, 1e-9));
CHECK(!card.active());
}
// CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to
// its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff
// (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop.
static void testAllSoundsOffStopsTriggerOneShot() {
// A Trigger sample with a very long play length (all-1 DC, flat velocity). After noteOn the
// voice is active and ringing; allSoundsOff must silence it immediately.
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
CHECK(eng.activeVoiceCount() == 1);
// CC 123 (release) must be a NO-OP on a Trigger voice — the one-shot plays through.
eng.allNotesOff();
CHECK(eng.activeVoiceCount() == 1); // still ringing (Trigger ignores release)
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(out[0] > 0.5f); // still sounding
// CC 120 (hard-stop) must silence it instantly.
eng.allSoundsOff();
CHECK(eng.activeVoiceCount() == 0); // immediately idle
out.clear();
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-9)); // silent
}
// CC 123 (allNotesOff) still releases Gate voices — the existing release behavior is unchanged.
static void testAllNotesOffStillReleasesGateVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
// Default Gate mode, instant release (releaseFrames 0).
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
CHECK(eng.activeVoiceCount() == 2);
eng.allNotesOff();
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-9)); // Gate with 0-release: instant silence
CHECK(eng.activeVoiceCount() == 0);
}
// MONO LEGATO same-note re-press (one-held-note edge case): with only that note on the
// stack, heldCount_ after the re-push is 1 (not >= 2), so it falls through to re-attack
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
static void testMonoLegatoSameNoteRepressReattacks() {
SampleData s = rampSample(200000, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // first press; read starts at 0
std::vector<AudioSample> out;
eng.render(out, 10); // advance the read head to ~10
// Re-press the SAME note while it is the only held note: heldCount_ after removeHeld+push = 1
// -> does NOT satisfy heldCount_ >= 2 -> re-attack (not a legato retune).
eng.noteOn(60, 127);
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (re-attack, not retune)
}
// GREEN: out-of-range notes are rejected at BOTH mono entry points. The held stack stores
// uint8, so an unguarded off for note 256 (== 0 mod 256) would alias-evict held note 0 —
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
static void testMonoOutOfRangeNotesRejected() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0);
eng.noteOn(0, 127); // hold the aliasing target (note 0)
eng.noteOn(62, 127); // 62 takes the voice; 0 held beneath
eng.noteOff(256); // MUST NOT alias-evict held note 0
eng.noteOff(-256); // likewise for the negative wrap
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // 62 undisturbed
eng.noteOff(62); // falls back to STILL-HELD note 0
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // (alias-evicted pre-fix -> silence here)
eng.noteOff(0);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
}
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
static void testVoiceCountBoundsPolyphony() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine e3(3, km);
CHECK(e3.maxVoices() == 3);
e3.noteOn(60, 127);
e3.noteOn(62, 127);
e3.noteOn(64, 127);
CHECK(e3.activeVoiceCount() == 3);
e3.noteOn(65, 127); // 4th: steals within the pool
CHECK(e3.activeVoiceCount() == 3);
VoiceEngine e1(1, km);
e1.noteOn(60, 127);
e1.noteOn(62, 127);
CHECK(e1.activeVoiceCount() == 1); // 1-voice pool: every note steals the one voice
VoiceEngine e0(0, km);
CHECK(e0.maxVoices() == 1); // documented degenerate: clamped to 1
}
// PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a
// preview, and pool stealing never touches the ringing preview. The two sum independently.
static void testPreviewCardIsolatedFromPool() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km);
PreviewCard card(km);
eng.noteOn(60, 127);
eng.noteOn(62, 127); // the pool is now FULL
card.noteOn(64, 127); // preview fires anyway — its own voice
CHECK(eng.activeVoiceCount() == 2); // no pool voice consumed
CHECK(card.active());
std::vector<AudioSample> buf(4, 0.0f);
eng.render(buf.data(), buf.size()); // engine sums 2 voices...
card.render(buf.data(), buf.size()); // ...card ADDS its own on top
CHECK(approx(buf[0], 3.0, 1e-6));
eng.noteOn(64, 127); // pool steals INTERNALLY...
CHECK(eng.activeVoiceCount() == 2);
CHECK(card.active()); // ...the preview is untouched
card.noteOff(64); // flat release: card gates off instantly
std::vector<AudioSample> buf2(1, 0.0f);
card.render(buf2.data(), buf2.size());
CHECK(approx(buf2[0], 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 2); // and the pool never noticed
}
// The card is ONE voice: a new preview replaces the ringing one, a STALE note-off (for the
// replaced note) is a no-op, and an out-of-zone preview is a defined no-play.
static void testPreviewCardReplaceStaleOffAndOutOfZone() {
Keymap km = twoLevelKeymap(); // zones [40,59] + [60,80]
PreviewCard card(km);
card.noteOn(50, 127);
card.noteOn(70, 127); // replaces the first preview
std::vector<AudioSample> buf(1, 0.0f);
card.render(buf.data(), buf.size());
CHECK(approx(buf[0], 0.75, 1e-6)); // zone B is what rings
card.noteOff(50); // STALE off for the replaced note: no-op
CHECK(card.active());
card.noteOff(70); // the sounding note's off gates it (release 0)
std::vector<AudioSample> buf2(1, 0.0f);
card.render(buf2.data(), buf2.size());
CHECK(approx(buf2[0], 0.0, 1e-9));
card.noteOn(20, 127); // out of every zone: defined no-play
CHECK(!card.active());
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
testFirstMatchOnOverlap();
testPitchRatioMath();
testKeyTrackedRatioMath();
testRepitchObservedPeriod();
testKeyTrackVarispeedObservedPeriod();
testKeyTrackPreserveShiftCollapsesAtZero();
testAdsrShape();
testAdsrReleaseBeforeSustain();
testAdsrZeroAttackDecay();
testPolyphonicAllocation();
testNoteOffReleasesNewestSameNote();
testOutOfZoneNoteConsumesNoVoice();
testStealsReleasingVoiceFirst();
testStealsOldestWhenNoneReleasing();
testLoopSustainSeamless();
testZeroLengthLoopGoesSilent();
testSingleFrameLoop();
testAbsentLoopGoesSilent();
testStartFrameOffsetsInitialRead();
testStartFrameZeroIsUnchanged();
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop();
testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain();
testPolyphonyMixesAdditively();
testChannelCount();
testStereoRenderKeepsChannelsDistinct();
testMonoSamplePlaysDualMonoInStereo();
testMonoRenderUnchangedByStereoData();
testStereoRenderAdvancesLikeMonoRepitch();
testStereoRenderSumsVoicesPerChannel();
testStereoRenderNullBufferIsNoOp();
testStereoStartFrameLoopShareOneReadHead();
// S15 — sampling modes.
testAhdsrHoldStageShape();
testAhdsrHoldZeroEqualsAdsr();
testTriggerLengthFractionFrames();
testTriggerLengthWithStart();
testTriggerFadeShape();
testTriggerEdgeCases();
testTriggerIgnoresNoteOff();
// S16 — pitch engine + pitch envelope.
testPreserveDurationInvariance();
testVarispeedStillCouplesDuration();
testPitchEnvOffBitIdentical();
testPitchEnvOnBendsVarispeed();
testPreserveGateStereoLoopComposes();
testPreserveVoiceCap();
// FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a
// uniform Preserve onset. Velocity under Preserve unchanged.
testPreserveUnityEngineVoiceKeepsUniformOnset();
testPreviewCardUnitySpeaksImmediately();
testPreviewCardKeyTrackZeroAlsoSpeaksImmediately();
testPreviewCardTransposedKeepsShifter();
testPreserveTransposedVoiceKeepsOlaPath();
testPreserveUnityVoiceCountsTowardCap();
testVelocityCurveAppliesUnderPreserve();
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
testPerZoneAdsrReachesVoiceEnvelope();
testZeroAdsrIsInstantSustain();
// Phase S — voice count, MONO mode (held stack + Retrigger/Legato), preview card.
testMonoLastNotePriorityAndFallback();
testMonoReleaseOfLowerHeldNoteIsInaudible();
testMonoRepressHeldNoteMovesToTop();
testMonoRetriggerFallbackUsesOriginalVelocity();
testMonoOutOfZoneNeverJoinsStack();
testMonoRetriggerRestartsEnvelope();
testMonoLegatoContinuesEnvelope();
testMonoLegatoRetunesWithoutReadRestart();
testMonoLegatoCrossSampleRestarts();
testMonoLegatoAfterReleaseReattacks();
testMonoIgnoresPreserveCap();
testMonoLegatoTriggerReattacksAfterKeyUp();
testMonoLegatoTriggerHeldKeyStillRetunes();
testAllNotesOffReleasesPolyVoices();
testAllNotesOffClearsMonoHeldStack();
testPreviewCardReleaseAll();
testAllSoundsOffStopsTriggerOneShot();
testAllNotesOffStillReleasesGateVoices();
testMonoLegatoSameNoteRepressReattacks();
testMonoOutOfRangeNotesRejected();
testVoiceCountBoundsPolyphony();
testPreviewCardIsolatedFromPool();
testPreviewCardReplaceStaleOffAndOutOfZone();
if (g_fail == 0) {
std::printf("all sampler_core tests passed\n");
return 0;
}
std::printf("%d sampler_core check(s) failed\n", g_fail);
return 1;
}