Files
reasampler/tests/test_sampler_core.cpp
T

2661 lines
127 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
// Loop-never-freezes regression: the Preserve voice must NOT spuriously freeze when the
// read tap hits the sample end and the loop wraps it back. A spuriously frozen voice
// stops writing to the ring and the looped tail would go silent past the sample end.
// Check a block far past the sample end (sample = 400 frames; window = 512; well past
// any single-pass tail region) to catch any wrap-before-exhaustion ordering error.
double maxLFar = 0.0;
for (std::size_t i = 1800; i < 2000; ++i) {
if (std::fabs(left[i]) > maxLFar) maxLFar = std::fabs(left[i]);
}
CHECK(maxLFar > 0.05); // still alive at frame 1800 (4.5× the 400-frame sample length)
}
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
// EVERY engine Preserve voice (root included) runs the shifter and counts toward the cap —
// the FA1 unity demotion is gone (it was scoped to the retired PreviewCard).
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 postscript — the Preserve unity-Varispeed bypass is REMOVED with the PreviewCard
// (preview is now a plain engine noteOn at the root). The engine keeps the shifter at
// EVERY Preserve note; GA2's primed ring speaks on frame 0 at every ratio, so onset is
// uniformly IMMEDIATE across the keyboard with no demotion path at all.
// ---------------------------------------------------------------------------
// The ENGINE'S root-note Preserve voice keeps the OLA path — and since the GA2 prime fix the
// primed shifter speaks on frame 0 at EVERY ratio (the ring holds the first window of real
// source, not warm-up zeros). Uniform onset across the keyboard now means uniformly IMMEDIATE:
// the root and a transposed neighbor both open at full level on the very first frames.
static void testPreserveUnityEngineVoiceSpeaksImmediately() {
auto earlyAndLate = [](int note, double& early, double& late) {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, 1500);
early = 1e9;
for (std::size_t i = 0; i < 8; ++i) {
early = (std::min)(early, static_cast<double>(std::fabs(out[i])));
}
late = 0.0;
for (std::size_t i = 600; i < 1500; ++i) {
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
}
};
double earlyRoot = 0.0, lateRoot = 0.0, earlyUp = 0.0, lateUp = 0.0;
earlyAndLate(60, earlyRoot, lateRoot); // at root: unity shift — NO demotion in the engine
earlyAndLate(62, earlyUp, lateUp); // +2 st: a real shift, same immediate onset
CHECK(earlyRoot > 0.9); // primed ring: full level from frame 0 (no fill silence)
CHECK(earlyUp > 0.9); // ...uniformly across the keyboard (the GA2 onset-gap fix)
CHECK(lateRoot > 0.9);
CHECK(lateUp > 0.9); // and no gaps later either (splices land in real history)
}
// A TRANSPOSED Preserve note keeps the genuine OLA path — and since the GA2 prime fix that
// path has NO onset cost: the ring is primed with the first window of real source, so a
// transposed voice opens at full level on frame 0 (the DAW "zero-sample gaps in the first few
// ms" regression) and NEVER dips while the source sustains (splices land in real history, not
// warm-up zeros).
static void testPreserveTransposedVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
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);
// Full level from the very first frame (a DC source through complementary crossfades and
// aligned splices holds 1.0 throughout) — pre-fix the first ~window was fill silence.
double lo = 1e9;
for (std::size_t i = 0; i < 1500; ++i) {
lo = (std::min)(lo, static_cast<double>(std::fabs(out[i])));
}
CHECK(lo > 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);
}
// 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);
}
// PREVIEW re-audition declick (GA2, re-homed on the engine): the preview is now a plain
// engine noteOn at the root, so re-auditioning while the first preview still rings is a
// POLY AT-CAP STEAL when the pool is saturated — with takeoverDeclick opted in (the shell's
// product default), the restart runs the same difference-seeded ramp: boundary continuity
// + bounded slope. Same physics testPolyStealDeclicksRestart pins; this pins it at the
// preview's exact shape (same note, root, full pool of 1).
static void testPreviewReauditionDeclicksViaEngineSteal() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
eng.noteOn(60, 127); // preview: root note through the pool
std::vector<AudioSample> pre;
eng.render(pre, 120); // ringing at ~ the sine peak
CHECK(pre.back() > 0.99f);
eng.noteOn(60, 127); // audition again: at-cap steal restart
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);
}
// 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 OBEYS VOICING (the PreviewCard-isolation reversal): a preview is a plain engine
// noteOn, so it is a REAL pool voice — a full pool STEALS for it (never a parallel voice on
// top), it counts toward activeVoiceCount, and its note-off releases through the normal
// path. This is the processor's mailbox-drain contract, pinned in the pure core.
static void testPreviewNoteObeysVoicing() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127); // the pool is now FULL
eng.noteOn(64, 127); // the preview note: steals — no third voice
CHECK(eng.activeVoiceCount() == 2);
std::vector<AudioSample> buf(4, 0.0f);
eng.render(buf.data(), buf.size());
CHECK(approx(buf[0], 2.0, 1e-6)); // TWO voices sum — never 3 (no side-car)
eng.noteOff(64); // flat release: the preview gates off
std::vector<AudioSample> buf2(1, 0.0f);
eng.render(buf2.data(), buf2.size());
CHECK(eng.activeVoiceCount() == 1); // the surviving MIDI note still rings
CHECK(approx(buf2[0], 1.0, 1e-6));
}
// PREVIEW JOINS THE MONO HELD STACK: a preview routed through the real note path is a mono
// stack entry like any host note — it TAKES the single voice on press (last-note priority)
// and its release FALLS BACK to the still-held host note instead of cutting to silence.
// Pins the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
// pins it for Poly steal.
static void testPreviewNoteJoinsMonoHeldStack() {
Keymap km = twoLevelKeymap();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // the host-MIDI note: zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // the preview press: TAKES the voice
CHECK(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // preview release: FALLBACK to the held note
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // host note up: gate off (flat release = instant)
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
}
// PREVIEW NOTE-OFF ROUTES TO THE DRAIN ENGINE: mirror of process()'s dual-engine off
// routing. A preview held across a reload leaves its ringing voice in the DISPLACED
// (draining) snapshot while the fresh live engine has no voice at that pitch. The off is
// sent to BOTH — exactly what the mailbox drain does: the fresh engine must safely no-op,
// the drain engine must release its voice (otherwise the old-snapshot preview would
// sustain until the next reload hard-cut it).
static void testPreviewNoteOffRoutesToDrainEngine() {
Keymap km = twoLevelKeymap();
VoiceEngine drainEng(2, km); // was live when the preview fired
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices
CHECK(drainEng.noteOn(70, 127) != VoiceEngine::kNoVoice);
CHECK(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
CHECK(liveEng.activeVoiceCount() == 0);
// The preview release, drained to BOTH engines like a host note-off:
liveEng.noteOff(70);
drainEng.noteOff(70);
CHECK(approx(probeFrame(liveEng), 0.0, 1e-9)); // fresh engine: safe no-op, stays silent
CHECK(liveEng.activeVoiceCount() == 0);
CHECK(approx(probeFrame(drainEng), 0.0, 1e-9)); // flat release: gates off NOW
CHECK(drainEng.activeVoiceCount() == 0); // the old-snapshot voice released
}
// GA2 — bounded-blend overshoot regression: mid-ramp output must stay within full scale.
//
// Construction of the worst case (§1 reviewer finding): retrig a sine at a point where the
// pre-cut level is ~1.0 (old ref ≈ 1). The new voice starts at sin(0) == 0, so the OLD
// frozen-seed declick adds (ref x₀) ≈ 1.0 to the compensation. The new sine has a short
// period (8 frames) so outₙ reaches ~1.0 again within just 2 frames; at that moment the
// frozen seed is still ~0.9 → outₙ + seed ≈ 1.9, roughly +3.8 dB over full scale.
//
// The bounded blend keeps every frame within max(|ref|, |outCurrent|) ≤ 1.0 + tol — this
// test must FAIL against the rev-2 frozen-seed code and PASS with the bounded blend.
static void testDeclickBoundedBlendNoOvershoot() {
// A sine with 8-frame period so it peaks within the declick ramp window (~80 frames).
// 48000 frames, 6000 cycles -> period = 8 frames; quarter period = 2 frames = the peak.
const std::size_t kFrames = 48000;
const double kCycles = 6000.0; // period = 8 frames
SampleData s = sineSample(kFrames, kCycles, 60);
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
// Start a voice and render to a quarter period so the sine is near its positive peak.
// Period = 48000/6000 = 8 frames. Frame index 2 = sin(2π*6000*2/48000) = sin(π/2) = 1.0.
// We render 3 frames (indices 0,1,2 are visited: readPos 0→1→2→3) so pre[2] reads
// frame index 2 at the sine peak.
eng.noteOn(60, 127);
std::vector<AudioSample> pre;
eng.render(pre, 3); // frame index 2 (read on third render): sin(pi/2) ≈ 1.0
CHECK(pre.back() > 0.99f); // at peak: ref ≈ 1.0 when we cut
// Retrigger: hard restart at sin(0) == 0, ref == ~1.0. The frozen-seed approach would
// add ~0.9 to a new output of ~1.0 two frames later → ~1.9. The bounded blend must not.
eng.noteOn(60, 127);
const double tol = 1e-3;
std::vector<AudioSample> post;
eng.render(post, 200); // 200 frames covers the full ramp (~80 frames at kDeclickDecay=0.95)
for (std::size_t i = 0; i < post.size(); ++i) {
const double v = static_cast<double>(post[i]);
CHECK(v <= 1.0 + tol && v >= -1.0 - tol);
}
// Boundary identity: first frame must reproduce the pre-cut level (±small tol).
CHECK(std::fabs(static_cast<double>(post[0]) - static_cast<double>(pre.back())) < 0.01);
}
// ===========================================================================
// GA3 — Preserve tail wind-down: the final window (and the release riding over it) must be
// a gap-free tone. The GA2 tail clamp HELD THE LAST REAL SAMPLE as the shifter feed once the
// source ran out — a DC plateau with no waveform to correlate on. Splices landing in (or
// referenced against) that region were unalignable, so the tap alternated real-tone / dead-DC
// at the splice cadence: the DAW "periodic troughs, almost like ring modulation, stronger
// toward the end, ~1:20 tone-to-silence at the very end". GA3 freezes the WRITER instead
// (padding never enters the ring) and lets the aligned-splice machinery recycle the frozen
// real tail — these tests render to the natural end and assert the tone survives.
// ===========================================================================
// A sine at explicit per-index frequency f0 (cycles/frame). Period is chosen NON-INTEGER
// (splice alignment must earn the sub-sample fit) but dividing `frames` exactly, so the
// source ENDS at a zero crossing — the held-DC value the GA2 clamp would feed is ~0, making
// the pre-GA3 dead stretches measurable as near-silence.
static SampleData tailSine(std::size_t frames, double f0, 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 * f0 * static_cast<double>(i)));
}
s.rootNote = rootNote;
return s;
}
// Longest run of consecutive frames with |x| < thresh in [from, to).
static std::size_t worstQuietRun(const std::vector<AudioSample>& out, std::size_t from,
std::size_t to, double thresh) {
std::size_t worst = 0, run = 0;
for (std::size_t i = from; i < to && i < out.size(); ++i) {
if (std::fabs(static_cast<double>(out[i])) < thresh) {
++run;
if (run > worst) worst = run;
} else {
run = 0;
}
}
return worst;
}
// Peak |x| over [from, from+len).
static double blockPeak(const std::vector<AudioSample>& out, std::size_t from, std::size_t len) {
double peak = 0.0;
for (std::size_t i = from; i < from + len && i < out.size(); ++i) {
const double a = std::fabs(static_cast<double>(out[i]));
if (a > peak) peak = a;
}
return peak;
}
// --- Gate no-loop, held to the natural end: the FINAL WINDOW carries the full-amplitude
// tone with no gaps, at up- AND down-shifts. Pre-GA3 this window chopped (RED without
// the writer freeze: quiet runs of hundreds of frames, block peaks collapsing to ~0.04). ---
static void testPreserveTailFinalWindowGapFree() {
const std::size_t frames = 8192;
const std::size_t w = 1024;
const double f0 = 1.0 / 163.84; // 50 exact cycles over 8192: ends at a zero crossing
const int notes[] = {67, 55}; // +7 st (ratio ~1.50) and -5 st (ratio ~0.75)
for (int note : notes) {
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end
s.play.adsr = flatAdsr(); // held: amp 1 to the end (isolates the DSP)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, frames); // the voice frees exactly at the natural end
// (a) No dead stretches: a unit sine at period ~164/ratio dwells below 0.05 for only
// a few frames per zero crossing; the pre-GA3 DC stretches ran hundreds.
CHECK(worstQuietRun(out, frames - w, frames, 0.05) < 24);
// (b) Full amplitude to the very end: every 128-frame block in the final window spans
// more than a half period at both ratios, so a clean tone peaks near 1.0 in each.
for (std::size_t b = frames - w; b + 128 <= frames; b += 128) {
CHECK(blockPeak(out, b, 128) > 0.5);
}
}
}
// --- Gate release OVER the final window: the envelope scales amplitude smoothly; the
// underlying tone must stay continuous (no chop) while it fades. Adjacent-block peaks
// may only decay envelope-fast, never gap-fast. ---
static void testPreserveTailReleaseContinuous() {
const std::size_t frames = 8192;
const std::size_t w = 1024;
const double f0 = 1.0 / 163.84;
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = static_cast<std::int64_t>(w); // release spans the final window
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
std::vector<AudioSample> out;
eng.render(out, frames - w); // sustain up to one window before the end...
eng.noteOff(67); // ...then release exactly over the final window
eng.render(out, w);
// First release block still near full level; thereafter each 128-frame block may lose at
// most envelope-rate level vs its predecessor (linear release loses 12.5% of full scale
// per block). A pre-GA3 chop collapses a mid-release block toward zero and fails the
// ratio bound; assert down to a floor where the fade itself bottoms out.
const std::size_t r0 = frames - w;
CHECK(blockPeak(out, r0, 128) > 0.5);
double prev = blockPeak(out, r0, 128);
for (std::size_t b = r0 + 128; b + 128 <= frames; b += 128) {
const double cur = blockPeak(out, b, 128);
if (prev >= 0.15) CHECK(cur >= 0.3 * prev);
prev = cur;
}
}
// --- Trigger one-shot to its play end (lengthFraction < 1 exercises the playEnd_ feed bound):
// the final window BEFORE the stop point is gap-free at an off-root pitch. ---
static void testPreserveTriggerTailGapFree() {
const std::size_t frames = 8192;
const std::size_t w = 1024;
const double f0 = 1.0 / 163.84;
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
const std::size_t playEnd = 6554; // round(0.8 * 8192)
std::vector<AudioSample> out;
eng.render(out, playEnd);
CHECK(worstQuietRun(out, playEnd - w, playEnd, 0.05) < 24);
for (std::size_t b = playEnd - w; b + 128 <= playEnd; b += 128) {
CHECK(blockPeak(out, b, 128) > 0.5);
}
}
// --- Q-W0 T1-03: the Preserve prime is bounded by the PLAYABLE span. A Trigger zone whose
// play length is shorter than the OLA window must never carry source PAST the user's
// chosen stop into the ring — pre-fix, the prime pulled a full window bounded only by
// frameCount, and an up-shifted tap PLAYED the cut content (transposed) before the voice
// freed. The sample poisons everything past playEnd with amplitude 8: if any of it
// reaches the output, the peak bound fails. ---
static void testPreservePrimeStopsAtTriggerPlayEnd() {
const std::size_t frames = 8000;
const std::size_t w = 2048; // OLA window >> playable span
const std::size_t playLen = 500; // playEnd = round(8000 * 0.0625) = 500
SampleData s;
s.frames.resize(frames);
const double f0 = 1.0 / 50.0; // 10 cycles inside the playable span
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = i < playLen
? static_cast<float>(std::sin(2.0 * kPi * f0 * static_cast<double>(i)))
: 8.0f; // POISON: cut content past the play end
}
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
// the deepest primed history the ring holds
std::vector<AudioSample> out;
eng.render(out, playLen + 64); // through the voice's own end (readPos >= playEnd)
double peak = 0.0;
for (const AudioSample v : out) {
const double a = std::fabs(static_cast<double>(v));
if (a > peak) peak = a;
}
CHECK(peak < 1.5); // the 8.0 poison never sounds: nothing past playEnd entered the ring
CHECK(peak > 0.4); // ...and the real span genuinely played (the bound is not vacuous)
}
// --- Q-W0 T1-03 (companion): a whole sample SHORTER than the window (Gate, no loop) must not
// get zero padding declared as valid ring history — pre-fix, the prime zero-filled the
// window remainder with filled_ = window, so splices/tap travel landed in silence:
// hundreds-of-frames dead runs inside a sub-window one-shot (the pre-GA2 burst/gap
// artifact re-entering for short material). Post-fix the prime stops at the sample end
// and freezes the tail immediately, so the ring recycles ONLY real content. ---
static void testPreserveSubWindowSampleNoZeroPadInRing() {
const std::size_t frames = 1200; // sample < one window
const std::size_t w = 2048;
const double f0 = 1.0 / 96.0; // period 96: zero crossings dwell ~2 frames
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
std::vector<AudioSample> out;
eng.render(out, frames); // voice runs to its natural end (no loop)
// Pre-fix: the tap crossed the declared-valid zero pad repeatedly — quiet runs of 150+
// frames. Post-fix every relocation stays inside the real filled span; only sine zero
// crossings dip below the threshold.
CHECK(worstQuietRun(out, 0, frames, 0.05) < 30);
CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level
}
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 postscript — the unity demotion is gone with the PreviewCard; the engine keeps a
// uniform Preserve onset at every note. Velocity under Preserve unchanged.
testPreserveUnityEngineVoiceSpeaksImmediately();
testPreserveTransposedVoiceSpeaksImmediately();
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 as a real
// pool voice (PreviewCard retired — preview routes through the engine).
testMonoLastNotePriorityAndFallback();
testMonoReleaseOfLowerHeldNoteIsInaudible();
testMonoRepressHeldNoteMovesToTop();
testMonoRetriggerFallbackUsesOriginalVelocity();
testMonoOutOfZoneNeverJoinsStack();
testMonoRetriggerRestartsEnvelope();
testMonoLegatoContinuesEnvelope();
testMonoLegatoRetunesWithoutReadRestart();
testMonoLegatoCrossSampleRestarts();
testMonoLegatoAfterReleaseReattacks();
testMonoIgnoresPreserveCap();
testMonoLegatoTriggerReattacksAfterKeyUp();
testMonoLegatoTriggerHeldKeyStillRetunes();
testAllNotesOffReleasesPolyVoices();
testAllNotesOffClearsMonoHeldStack();
testAllSoundsOffStopsTriggerOneShot();
testAllNotesOffStillReleasesGateVoices();
testMonoLegatoSameNoteRepressReattacks();
testMonoOutOfRangeNotesRejected();
testVoiceCountBoundsPolyphony();
testOverCapChordStealsExactlyOne();
testMonoRetrigTakeoverDeclicksRestart();
testMonoRetrigFallbackDeclicksRestart();
testMonoDeclickOnlyOnTakeover();
testPolyStealDeclicksRestart();
testSameBlockDoubleTakeoverKeepsDeclickSeed();
testZeroAttackTakeoverNeverExceedsFullScale();
testMonoRetrigTriggerZoneDeclicksRestart();
testZeroAttackGateRetrigNoStep();
testPreviewReauditionDeclicksViaEngineSteal();
testDeclickBoundedBlendNoOvershoot();
testPreviewNoteObeysVoicing();
testPreviewNoteJoinsMonoHeldStack();
testPreviewNoteOffRoutesToDrainEngine();
// GA3 — Preserve tail wind-down (writer freeze at source exhaustion).
testPreserveTailFinalWindowGapFree();
testPreserveTailReleaseContinuous();
testPreserveTriggerTailGapFree();
// Q-W0 T1-03 — the prime is bounded by the playable span (Trigger playEnd / sample end),
// with an immediate tail freeze on sub-window spans.
testPreservePrimeStopsAtTriggerPlayEnd();
testPreserveSubWindowSampleNoZeroPadInRing();
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;
}