Files
reasampler/tests/test_sampler_core.cpp
T

2452 lines
114 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 testDualMonoStereoSampleRendersCentered() {
// GA Bug 1 (pure-layer proof): a STEREO sample whose two channels are IDENTICAL (a
// dual-mono capture) must render EXACTLY equal L and R — bitwise, every frame — under
// BOTH pitch engines. Any asymmetry here (a silent L, a channel offset, divergent
// shifter state) would pan the output; hard-panned output from a dual-mono capture
// therefore cannot originate in the engine.
auto renderBoth = [](PitchEngine engine, int note) {
SampleData s = sineSample(600, 12.0, 60);
s.framesR = s.frames; // dual-mono: identical channels
s.play.pitchEngine = engine;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
eng.noteOn(note, 127);
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
eng.render(left.data(), right.data(), 256);
bool sound = false, equal = true;
for (std::size_t i = 0; i < left.size(); ++i) {
if (left[i] != 0.0f) sound = true;
if (left[i] != right[i]) equal = false; // EXACT: dual-mono must be centered
}
CHECK(sound); // the render actually produced signal (a 0==0 pass would be vacuous)
CHECK(equal);
};
renderBoth(PitchEngine::Varispeed, 60);
renderBoth(PitchEngine::Varispeed, 67); // off-root: repitch rides both channels equally
renderBoth(PitchEngine::Preserve, 60); // both shifters run (no unity demotion in MIDI)
renderBoth(PitchEngine::Preserve, 67); // off-root Preserve: per-channel shift, same state
}
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
}
// GA declick (bug 2): a MONO Retrigger TAKEOVER hard-cuts the sounding tone (read head +
// envelope restart in one frame) — pre-fix the output stepped from the old level to the new
// attack's ~0 in one sample, the audible click. With the engine's takeoverDeclick opt-in
// the boundary frame carries the old level and every later frame moves by a bounded small
// delta while the compensation decays under the new attack.
static void testMonoRetrigTakeoverDeclicksRestart() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 100; // real attack: the new tone starts near 0
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 200); // past the attack: sustained at 1.0
CHECK(approx(pre.back(), 1.0, 1e-6));
eng.noteOn(64, 127); // Retrigger takeover: hard restart
std::vector<AudioSample> post;
eng.render(post, 400);
// No step at the boundary: the first post-takeover frame still carries the old level
// (pre-fix it was the new attack's ~0 — a full-scale step).
CHECK(approx(post[0], 1.0, 0.06));
// Bounded slope everywhere across the takeover: max per-frame delta is the declick decay
// step (~0.05) + the attack slope (0.01), never a click-sized jump.
double prev = static_cast<double>(pre.back());
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
CHECK(maxDelta < 0.07);
// The compensation dies out: the tail is the new note's sustain alone.
CHECK(approx(post.back(), 1.0, 1e-3));
}
// Peer restart site (peer-symmetry): the Retrigger FALLBACK on note-off — the most-recent
// still-held note re-strikes the voice — is the same hard cut and gets the same declick.
static void testMonoRetrigFallbackDeclicksRestart() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> a;
eng.render(a, 400); // 60 sustains at 1.0
eng.noteOn(64, 127); // takeover (declicked, settles back to 1.0)
std::vector<AudioSample> b;
eng.render(b, 400);
CHECK(approx(b.back(), 1.0, 1e-3));
eng.noteOff(64); // FALLBACK re-strikes held 60 — hard restart
std::vector<AudioSample> post;
eng.render(post, 400);
CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step
double prev = static_cast<double>(b.back());
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
CHECK(maxDelta < 0.07);
CHECK(approx(post.back(), 1.0, 1e-3));
}
// The declick is TAKEOVER-only: a fresh mono start (idle voice — first note of a phrase, or
// a re-press after a full gate-off) must NOT ramp from a stale last output; the attack starts
// at ~0 exactly as before.
static void testMonoDeclickOnlyOnTakeover() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
// First note of the phrase: no phantom compensation, attack from ~0.
eng.noteOn(60, 127);
CHECK(probeFrame(eng) < 0.02);
std::vector<AudioSample> a;
eng.render(a, 400); // sustain 1.0 (lastOut is now nonzero)
// Full gate-off (release 0 -> instant idle): the next start is FRESH, not a takeover.
eng.noteOff(60);
std::vector<AudioSample> gap;
eng.render(gap, 4);
CHECK(approx(gap.back(), 0.0, 1e-9));
eng.noteOn(62, 127);
CHECK(probeFrame(eng) < 0.02); // no declick from the stale last output
}
// Peer restart site (peer-symmetry): a POLY at-cap STEAL is the same hard cut as the mono
// retrig takeover — read head + envelope restart on a SOUNDING voice — and gets the same
// declick ramp. Pool of 1 makes the steal deterministic: the second note-on must steal the
// only (sounding) voice, and with the opt-in the boundary carries the old level instead of
// stepping to the new attack's ~0.
static void testPolyStealDeclicksRestart() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 200); // past the attack: sustained at 1.0
CHECK(approx(pre.back(), 1.0, 1e-6));
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // at cap: steals the sounding voice
std::vector<AudioSample> post;
eng.render(post, 400);
CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step
double prev = static_cast<double>(pre.back());
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
CHECK(maxDelta < 0.07);
CHECK(approx(post.back(), 1.0, 1e-3)); // compensation dies out; new note sustains
}
// SAME-BLOCK double takeover: two steals of the same voice with NO frame rendered between
// (a two-note chord arriving at cap in one block). The second start() must re-seed the ramp
// from the same pre-cut output level — if start() zeroed lastOut, the pending ramp would be
// dropped and the click would return on exactly this edge.
static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 200); // sustained at 1.0
CHECK(approx(pre.back(), 1.0, 1e-6));
eng.noteOn(64, 127); // steal #1 (no render yet)
eng.noteOn(67, 127); // steal #2, same block
std::vector<AudioSample> post;
eng.render(post, 400);
CHECK(approx(post[0], 1.0, 0.06)); // seed survived the double restart
double prev = static_cast<double>(pre.back());
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
CHECK(maxDelta < 0.07);
CHECK(approx(post.back(), 1.0, 1e-3));
}
// Declick with a ZERO-ATTACK takeover onto the SAME DC level: the difference seed is
// (old level new first raw output) = (1.0 1.0) = 0, so NOTHING is added — output stays
// exactly full scale, never above it. (This is the case the retired rev-1 (1 amp) gate
// existed for: an ADDITIVE old-level ramp under an instant-unity attack summed to +6 dB.
// The difference seed makes the blip structurally impossible without any gate — and without
// the gate's fatal hole that kept the click on every instant-unity restart.)
static void testZeroAttackTakeoverNeverExceedsFullScale() {
SampleData s = dcSample(200000, 60);
s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 200); // sustained at 1.0
CHECK(approx(pre.back(), 1.0, 1e-6));
eng.noteOn(64, 127); // zero-attack takeover: amp hits 1 on frame 0
std::vector<AudioSample> post;
eng.render(post, 400);
// Every output frame must stay within [-1, 1]: no +6 dB blip.
for (AudioSample v : post) {
CHECK(v <= 1.0f + 1e-4f && v >= -1.0f - 1e-4f);
}
// The zero-attack note settles at sustain 1.0 immediately.
CHECK(approx(post[0], 1.0, 1e-4));
}
// Shared discontinuity probe for the GA2 click tests: max sample-to-sample delta from the
// last pre-restart frame across the whole post-restart span. A hard cut shows up as a
// click-sized step (~ the old instantaneous level); a properly declicked restart moves by
// the signal's own slope plus the ≤5%-of-seed decay step per frame.
static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& post) {
double prev = lastPre;
double maxDelta = 0.0;
for (AudioSample v : post) {
const double d = std::fabs(static_cast<double>(v) - prev);
if (d > maxDelta) maxDelta = d;
prev = static_cast<double>(v);
}
return maxDelta;
}
// GA2 — the click that SURVIVED the rev-1 declick (DAW report: "mono retrigger STILL
// CLICKS"): a mono Retrigger takeover of a TRIGGER zone. Trigger with no fade-in is at FULL
// amplitude on frame 0, so the rev-1 compensation — gated by (1 amp) — was zeroed exactly
// here and the restart still hard-cut from the old instantaneous level (~1.0 at the sine
// peak) to the new onset's 0. The difference-seeded declick reproduces the old level on the
// boundary frame and bounds every later delta. A sine (not DC) so the test sees the real
// waveform-value jump the DC-sample rev-1 tests masked.
static void testMonoRetrigTriggerZoneDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame
s.play.playMode = PlayMode::Trigger; // default fades: NO fade-in -> amp 1 at frame 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 120); // quarter period: ringing at ~ the sine peak
CHECK(pre.back() > 0.99f); // the cut level is large — a real click pre-fix
eng.noteOn(60, 127); // hammer the same key: Retrigger takeover
std::vector<AudioSample> post;
eng.render(post, 400);
// Boundary continuity: the first post-restart frame reproduces the old level (pre-fix it
// stepped to the new onset's sin(0) == 0 — a full-scale discontinuity).
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
// Bounded slope across the whole restart: decay step (<= 0.05 of the seed) + sine slope.
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// GA2 peer: the same gate hole on a GATE zone with ZERO attack (amp == 1 on frame 0 — the
// default AdsrParams, and any user-dialed instant attack). Rev-1's (1 amp) gate zeroed the
// compensation here too; the difference seed closes it identically.
static void testZeroAttackGateRetrigNoStep() {
SampleData s = sineSample(48000, 100.0, 60);
s.play.adsr.attackFrames = 0; // instant-unity attack
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 120); // ringing at ~ the sine peak
CHECK(pre.back() > 0.99f);
eng.noteOn(60, 127); // zero-attack Retrigger takeover
std::vector<AudioSample> post;
eng.render(post, 400);
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// GA2 — the PREVIEW click: a preview fired over a RINGING preview replaces the card's single
// voice — the same hard cut as a mono takeover, previously entirely un-declicked (the card
// never passed the opt-in). With takeoverDeclick opted in at construction, the
// replace-restart runs the same difference-seeded ramp: boundary continuity + bounded slope.
static void testPreviewRetriggerDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km, 0, /*takeoverDeclick=*/true);
card.noteOn(60, 127);
std::vector<AudioSample> pre(120, 0.0f);
card.render(pre.data(), pre.size()); // ringing at ~ the sine peak
CHECK(pre.back() > 0.99f);
card.noteOn(60, 127); // audition again: replaces the ringing preview
std::vector<AudioSample> post(400, 0.0f);
card.render(post.data(), post.size());
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
CHECK(maxDeltaAcross(static_cast<double>(pre.back()), post) < 0.08);
}
// The preview declick is OPT-IN: a default-constructed card keeps the pre-fix hard cut
// byte-identical — the replace-restart's first frame is the new voice's raw onset (sin(0)
// == 0 here), pinning the pure-core regression baseline the shell layers the opt-in above.
static void testPreviewDefaultOffKeepsHardCutBaseline() {
SampleData s = sineSample(48000, 100.0, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
PreviewCard card(km); // declick NOT opted in
card.noteOn(60, 127);
std::vector<AudioSample> pre(120, 0.0f);
card.render(pre.data(), pre.size());
CHECK(pre.back() > 0.99f);
card.noteOn(60, 127);
std::vector<AudioSample> post(1, 0.0f);
card.render(post.data(), post.size());
CHECK(approx(post[0], 0.0, 1e-4)); // the raw hard cut: new onset, no ramp
}
// GA-VoiceSteal repro (DAW bug): voiceCount 3, a triad note-on'd at the SAME sample time
// (three note-ons in one block, no render between), then a 4th note. The steal must take
// EXACTLY ONE voice (the oldest, none releasing) and leave the other two RINGING — the DAW
// symptom was every tone cutting out. Configured like the live instrument: Preserve engine
// (product default), a real OLA window, sine PCM, default-ish AHDSR (3 ms attack, sustain 1,
// 60 ms release), rendered stereo between events like process() does.
static void testOverCapChordStealsExactlyOne() {
SampleData s = sineSample(96000, 2000.0, 60); // ~2 s at 48k
s.play.adsr.attackFrames = 144; // 3 ms @ 48k
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
// Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400);
// The chord: three note-ons at one sample time (same block, no render between).
CHECK(eng.noteOn(60, 100) != VoiceEngine::kNoVoice);
CHECK(eng.noteOn(64, 100) != VoiceEngine::kNoVoice);
CHECK(eng.noteOn(67, 100) != VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 3);
// Ring for a while (stereo, like the negotiated bus) — all three still sounding and finite.
std::vector<AudioSample> l(4800, 0.0f), r(4800, 0.0f);
eng.render(l.data(), r.data(), l.size());
CHECK(eng.activeVoiceCount() == 3);
bool finite = true;
for (AudioSample v : l) { if (!std::isfinite(v)) { finite = false; break; } }
CHECK(finite);
// The 4th note: must steal exactly ONE voice (the oldest = note 60) — never all.
CHECK(eng.noteOn(62, 100) != VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 3);
// Note 60 was the stolen one: its note-off finds no voice (count unchanged after the
// release window). Notes 64 and 67 must still hold their voices — each note-off drops
// the count by one once the 60 ms release tail has run out.
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
eng.noteOff(60);
eng.render(l.data(), r.data(), l.size()); // 4800 frames > 2880 release
CHECK(eng.activeVoiceCount() == 3); // 60 no longer owns a voice: no-op
eng.noteOff(64);
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
eng.render(l.data(), r.data(), l.size());
CHECK(eng.activeVoiceCount() == 2); // 64 was still ringing — ONE voice released
eng.noteOff(67);
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
eng.render(l.data(), r.data(), l.size());
CHECK(eng.activeVoiceCount() == 1); // 67 was still ringing too
eng.noteOff(62);
std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f);
eng.render(l.data(), r.data(), l.size());
CHECK(eng.activeVoiceCount() == 0); // the stolen-into 4th note releases last
}
// 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();
testDualMonoStereoSampleRendersCentered();
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();
testOverCapChordStealsExactlyOne();
testMonoRetrigTakeoverDeclicksRestart();
testMonoRetrigFallbackDeclicksRestart();
testMonoDeclickOnlyOnTakeover();
testPolyStealDeclicksRestart();
testSameBlockDoubleTakeoverKeepsDeclickSeed();
testZeroAttackTakeoverNeverExceedsFullScale();
testMonoRetrigTriggerZoneDeclicksRestart();
testZeroAttackGateRetrigNoStep();
testPreviewRetriggerDeclicksRestart();
testPreviewDefaultOffKeepsHardCutBaseline();
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;
}