// 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 #include #include #include 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(std::sin(2.0 * kPi * cycles * static_cast(i) / static_cast(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& out) { std::vector 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(upCrossings[i] - upCrossings[i - 1]); } return sum / static_cast(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(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 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 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 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(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 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 { 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(window)); eng.noteOn(note, 127); std::vector 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 offRootNoTrack = renderPreserve(67, 0.0); const std::vector 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 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 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 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 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 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(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 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 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(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 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 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(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 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(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 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 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(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 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 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 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 out; eng.render(out, 1); CHECK(approx(out[0], 1.0, 1e-4)); } { VoiceEngine eng(1, km); eng.noteOn(60, 64); std::vector 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 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 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 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 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 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 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 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(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(std::sin(2.0 * kPi * cycles * static_cast(i) / static_cast(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 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 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 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(i) * 0.01f; // L: 0.00 .. 0.39 s.framesR[i] = static_cast(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 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 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 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 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 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 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 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 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 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(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(atUp), static_cast(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 { 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 out; eng.render(out, n); return out; }; const std::vector a = renderOne(false); const std::vector 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 out; eng.render(out, 4000); // Early period (heavily transposed up) should be shorter than the late period (settled). std::vector early(out.begin(), out.begin() + 800); std::vector 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(std::sin(2.0 * kPi * 8.0 * static_cast(i) / static_cast(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 left(2000, 0.f), right(2000, 0.f); eng.render(left.data(), right.data(), 2000); // The loop sustains the voice well past the sample length (400 frames) -> still active. CHECK(eng.activeVoiceCount() == 1); // Both channels carry signal (some frame has non-trivial magnitude on each). double maxL = 0.0, maxR = 0.0; for (std::size_t i = 600; i < 2000; ++i) { if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]); if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]); } CHECK(maxL > 0.05); CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved } // --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- // Since the Phase S re-scope EVERY engine Preserve voice (root included) runs the shifter and // counts toward the cap — the unity demotion is preview-card-only (see the Phase S section). static void testPreserveVoiceCap() { SampleData s = dcSample(2000, 60); s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) Keymap km = Keymap::singleSampleChromatic(std::move(s)); // 8 voices total, Preserve cap of 2. VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap CHECK(eng.activeVoiceCount() == 2); } // --------------------------------------------------------------------------- // FA1 (re-scoped by Phase S) — the Preserve unity-Varispeed bypass now belongs to the PREVIEW // CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note. GA2 update: the primed // shifter speaks on frame 0 at every ratio (the ring holds the first window of real source), // so onset is uniformly IMMEDIATE across the keyboard and the bypass survives purely as a // per-frame work-skip for the card. // --------------------------------------------------------------------------- // 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 out; eng.render(out, 1500); early = 1e9; for (std::size_t i = 0; i < 8; ++i) { early = (std::min)(early, static_cast(std::fabs(out[i]))); } late = 0.0; for (std::size_t i = 600; i < 1500; ++i) { late = (std::max)(late, static_cast(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) } // The PREVIEW CARD at unity speaks on frame ONE — the FA1 latency fix, now scoped to the card. static void testPreviewCardUnitySpeaksImmediately() { SampleData s = dcSample(2000, 60); s.play.pitchEngine = PitchEngine::Preserve; s.play.adsr = flatAdsr(); Keymap km = Keymap::singleSampleChromatic(std::move(s)); PreviewCard card(km, /*preserveWindowFrames=*/512); card.noteOn(60, 127); // at root: unity shift -> demoted inside the card, zero onset delay std::vector buf(4, 0.0f); card.render(buf.data(), buf.size()); CHECK(approx(buf[0], 1.0, 1e-6)); // the DC sample, on the very first frame } // keyTrack 0 collapses EVERY note to unity — an off-root preview also demotes, speaks at once. static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() { SampleData s = dcSample(2000, 60); s.play.pitchEngine = PitchEngine::Preserve; s.play.adsr = flatAdsr(); Keymap km = Keymap::singleSampleChromatic(std::move(s)); km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity) PreviewCard card(km, /*preserveWindowFrames=*/512); card.noteOn(67, 127); std::vector buf(4, 0.0f); card.render(buf.data(), buf.size()); CHECK(approx(buf[0], 1.0, 1e-6)); } // A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY. Since // the GA2 prime fix onset silence can no longer distinguish the paths (both speak on frame 0), // so prove it by DURATION: a Preserve Trigger at 100% of a 1000-frame sample holds ~1000 // output frames at +12 st, where a Varispeed demotion would run off in ~500. static void testPreviewCardTransposedKeepsShifter() { SampleData s = preserveTriggerSample(1000, 1.0); Keymap km = Keymap::singleSampleChromatic(std::move(s)); PreviewCard card(km, /*preserveWindowFrames=*/512); card.noteOn(72, 127); // +12 semitones: a real shift, NOT demoted std::vector buf(1, 0.0f); std::size_t len = 0; for (std::size_t f = 0; f < 2000; ++f) { buf[0] = 0.0f; card.render(buf.data(), 1); if (card.active()) len = f + 1; else break; } CHECK(len > 700); // duration held (Preserve) — a Varispeed demotion would stop near 500 CHECK(len < 1300); // ...and not doubled either (sanity) } // 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 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(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 out; eng.render(out, 1000); return static_cast(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 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 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 out; eng.render(out, 1); return static_cast(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 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 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(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 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 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(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 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 out; eng.render(out, 10); eng.noteOn(62, 127); // 60 still held -> legato takeover CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack } // MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence). static void testAllNotesOffReleasesPolyVoices() { SampleData s = dcLevelSample(200000, 1.0f, 60); Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(4, km); eng.noteOn(60, 127); eng.noteOn(62, 127); eng.noteOn(64, 127); CHECK(eng.activeVoiceCount() == 3); eng.allNotesOff(); CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant) CHECK(eng.activeVoiceCount() == 0); } // MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry // (simulating a LOST note-off) can never be resurrected by the fallback afterwards. static void testAllNotesOffClearsMonoHeldStack() { Keymap km = twoLevelKeymap(); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); eng.noteOn(50, 127); // 50's note-off will never arrive (phantom) eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack eng.allNotesOff(); // PANIC CHECK(approx(probeFrame(eng), 0.0, 1e-9)); CHECK(eng.activeVoiceCount() == 0); // The stack is empty: a fresh press + release gates off cleanly, with NO fallback // restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever). eng.noteOn(70, 127); CHECK(approx(probeFrame(eng), 0.75, 1e-6)); eng.noteOff(70); CHECK(approx(probeFrame(eng), 0.0, 1e-9)); CHECK(eng.activeVoiceCount() == 0); } // MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer). static void testPreviewCardReleaseAll() { Keymap km = twoLevelKeymap(); PreviewCard card(km); card.noteOn(70, 127); CHECK(card.active()); card.releaseAll(); // no note argument: quiets whatever rings std::vector buf(1, 0.0f); card.render(buf.data(), buf.size()); CHECK(approx(buf[0], 0.0, 1e-9)); CHECK(!card.active()); } // CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to // its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff // (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop. static void testAllSoundsOffStopsTriggerOneShot() { // A Trigger sample with a very long play length (all-1 DC, flat velocity). After noteOn the // voice is active and ringing; allSoundsOff must silence it immediately. SampleData s = dcLevelSample(200000, 1.0f, 60); s.play.playMode = PlayMode::Trigger; s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(1, km); eng.noteOn(60, 127); CHECK(eng.activeVoiceCount() == 1); // CC 123 (release) must be a NO-OP on a Trigger voice — the one-shot plays through. eng.allNotesOff(); CHECK(eng.activeVoiceCount() == 1); // still ringing (Trigger ignores release) std::vector 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 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 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 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 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(pre.back()); double maxDelta = 0.0; for (AudioSample v : post) { const double d = std::fabs(static_cast(v) - prev); if (d > maxDelta) maxDelta = d; prev = static_cast(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 a; eng.render(a, 400); // 60 sustains at 1.0 eng.noteOn(64, 127); // takeover (declicked, settles back to 1.0) std::vector 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 post; eng.render(post, 400); CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step double prev = static_cast(b.back()); double maxDelta = 0.0; for (AudioSample v : post) { const double d = std::fabs(static_cast(v) - prev); if (d > maxDelta) maxDelta = d; prev = static_cast(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 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 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 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 post; eng.render(post, 400); CHECK(approx(post[0], 1.0, 0.06)); // boundary carries the old level, no step double prev = static_cast(pre.back()); double maxDelta = 0.0; for (AudioSample v : post) { const double d = std::fabs(static_cast(v) - prev); if (d > maxDelta) maxDelta = d; prev = static_cast(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 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 post; eng.render(post, 400); CHECK(approx(post[0], 1.0, 0.06)); // seed survived the double restart double prev = static_cast(pre.back()); double maxDelta = 0.0; for (AudioSample v : post) { const double d = std::fabs(static_cast(v) - prev); if (d > maxDelta) maxDelta = d; prev = static_cast(v); } CHECK(maxDelta < 0.07); CHECK(approx(post.back(), 1.0, 1e-3)); } // Declick with ZERO-ATTACK takeover: the new voice reaches full level on frame 0 (amp == 1), // so the gated compensation adds nothing (gate = 1 - 1 = 0). Output on the boundary frame is // exactly the new voice's level, never exceeding full scale. Without the (1-amp) gate a zero- // attack takeover from a sustained voice produced newOnset + oldLevel -> up to 2x (+6 dB). 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 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 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)); } // 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 l(4800, 0.0f), r(4800, 0.0f); eng.render(l.data(), r.data(), l.size()); CHECK(eng.activeVoiceCount() == 3); bool finite = true; for (AudioSample v : l) { if (!std::isfinite(v)) { finite = false; break; } } CHECK(finite); // The 4th note: must steal exactly ONE voice (the oldest = note 60) — never all. CHECK(eng.noteOn(62, 100) != VoiceEngine::kNoVoice); CHECK(eng.activeVoiceCount() == 3); // Note 60 was the stolen one: its note-off finds no voice (count unchanged after the // release window). Notes 64 and 67 must still hold their voices — each note-off drops // the count by one once the 60 ms release tail has run out. std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f); eng.noteOff(60); eng.render(l.data(), r.data(), l.size()); // 4800 frames > 2880 release CHECK(eng.activeVoiceCount() == 3); // 60 no longer owns a voice: no-op eng.noteOff(64); std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f); eng.render(l.data(), r.data(), l.size()); CHECK(eng.activeVoiceCount() == 2); // 64 was still ringing — ONE voice released eng.noteOff(67); std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f); eng.render(l.data(), r.data(), l.size()); CHECK(eng.activeVoiceCount() == 1); // 67 was still ringing too eng.noteOff(62); std::fill(l.begin(), l.end(), 0.0f); std::fill(r.begin(), r.end(), 0.0f); eng.render(l.data(), r.data(), l.size()); CHECK(eng.activeVoiceCount() == 0); // the stolen-into 4th note releases last } // PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a // preview, and pool stealing never touches the ringing preview. The two sum independently. static void testPreviewCardIsolatedFromPool() { SampleData s = dcLevelSample(200000, 1.0f, 60); Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(2, km); PreviewCard card(km); eng.noteOn(60, 127); eng.noteOn(62, 127); // the pool is now FULL card.noteOn(64, 127); // preview fires anyway — its own voice CHECK(eng.activeVoiceCount() == 2); // no pool voice consumed CHECK(card.active()); std::vector buf(4, 0.0f); eng.render(buf.data(), buf.size()); // engine sums 2 voices... card.render(buf.data(), buf.size()); // ...card ADDS its own on top CHECK(approx(buf[0], 3.0, 1e-6)); eng.noteOn(64, 127); // pool steals INTERNALLY... CHECK(eng.activeVoiceCount() == 2); CHECK(card.active()); // ...the preview is untouched card.noteOff(64); // flat release: card gates off instantly std::vector buf2(1, 0.0f); card.render(buf2.data(), buf2.size()); CHECK(approx(buf2[0], 0.0, 1e-9)); CHECK(eng.activeVoiceCount() == 2); // and the pool never noticed } // The card is ONE voice: a new preview replaces the ringing one, a STALE note-off (for the // replaced note) is a no-op, and an out-of-zone preview is a defined no-play. static void testPreviewCardReplaceStaleOffAndOutOfZone() { Keymap km = twoLevelKeymap(); // zones [40,59] + [60,80] PreviewCard card(km); card.noteOn(50, 127); card.noteOn(70, 127); // replaces the first preview std::vector buf(1, 0.0f); card.render(buf.data(), buf.size()); CHECK(approx(buf[0], 0.75, 1e-6)); // zone B is what rings card.noteOff(50); // STALE off for the replaced note: no-op CHECK(card.active()); card.noteOff(70); // the sounding note's off gates it (release 0) std::vector buf2(1, 0.0f); card.render(buf2.data(), buf2.size()); CHECK(approx(buf2[0], 0.0, 1e-9)); card.noteOn(20, 127); // out of every zone: defined no-play CHECK(!card.active()); } int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); testFirstMatchOnOverlap(); testPitchRatioMath(); testKeyTrackedRatioMath(); testRepitchObservedPeriod(); testKeyTrackVarispeedObservedPeriod(); testKeyTrackPreserveShiftCollapsesAtZero(); testAdsrShape(); testAdsrReleaseBeforeSustain(); testAdsrZeroAttackDecay(); testPolyphonicAllocation(); testNoteOffReleasesNewestSameNote(); testOutOfZoneNoteConsumesNoVoice(); testStealsReleasingVoiceFirst(); testStealsOldestWhenNoneReleasing(); testLoopSustainSeamless(); testZeroLengthLoopGoesSilent(); testSingleFrameLoop(); testAbsentLoopGoesSilent(); testStartFrameOffsetsInitialRead(); testStartFrameZeroIsUnchanged(); testStartFrameOutOfRangeClampsToZero(); testStartFrameWithLoop(); testStartAfterLoopEndWrapsIntoLoop(); testVelocityDefaultCurveIsFlatUnity(); testVelocityLinearCurveReproducesRamp(); testVelocityShapedCurveDrivesGain(); testPolyphonyMixesAdditively(); testChannelCount(); testStereoRenderKeepsChannelsDistinct(); testMonoSamplePlaysDualMonoInStereo(); testDualMonoStereoSampleRendersCentered(); testMonoRenderUnchangedByStereoData(); testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderSumsVoicesPerChannel(); testStereoRenderNullBufferIsNoOp(); testStereoStartFrameLoopShareOneReadHead(); // S15 — sampling modes. testAhdsrHoldStageShape(); testAhdsrHoldZeroEqualsAdsr(); testTriggerLengthFractionFrames(); testTriggerLengthWithStart(); testTriggerFadeShape(); testTriggerEdgeCases(); testTriggerIgnoresNoteOff(); // S16 — pitch engine + pitch envelope. testPreserveDurationInvariance(); testVarispeedStillCouplesDuration(); testPitchEnvOffBitIdentical(); testPitchEnvOnBendsVarispeed(); testPreserveGateStereoLoopComposes(); testPreserveVoiceCap(); // FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a // uniform Preserve onset. Velocity under Preserve unchanged. testPreserveUnityEngineVoiceSpeaksImmediately(); testPreviewCardUnitySpeaksImmediately(); testPreviewCardKeyTrackZeroAlsoSpeaksImmediately(); testPreviewCardTransposedKeepsShifter(); 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 card. testMonoLastNotePriorityAndFallback(); testMonoReleaseOfLowerHeldNoteIsInaudible(); testMonoRepressHeldNoteMovesToTop(); testMonoRetriggerFallbackUsesOriginalVelocity(); testMonoOutOfZoneNeverJoinsStack(); testMonoRetriggerRestartsEnvelope(); testMonoLegatoContinuesEnvelope(); testMonoLegatoRetunesWithoutReadRestart(); testMonoLegatoCrossSampleRestarts(); testMonoLegatoAfterReleaseReattacks(); testMonoIgnoresPreserveCap(); testMonoLegatoTriggerReattacksAfterKeyUp(); testMonoLegatoTriggerHeldKeyStillRetunes(); testAllNotesOffReleasesPolyVoices(); testAllNotesOffClearsMonoHeldStack(); testPreviewCardReleaseAll(); testAllSoundsOffStopsTriggerOneShot(); testAllNotesOffStillReleasesGateVoices(); testMonoLegatoSameNoteRepressReattacks(); testMonoOutOfRangeNotesRejected(); testVoiceCountBoundsPolyphony(); testOverCapChordStealsExactlyOne(); testMonoRetrigTakeoverDeclicksRestart(); testMonoRetrigFallbackDeclicksRestart(); testMonoDeclickOnlyOnTakeover(); testPolyStealDeclicksRestart(); testSameBlockDoubleTakeoverKeepsDeclickSeed(); testZeroAttackTakeoverNeverExceedsFullScale(); testPreviewCardIsolatedFromPool(); testPreviewCardReplaceStaleOffAndOutOfZone(); if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; } std::printf("%d sampler_core check(s) failed\n", g_fail); return 1; }