// 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 <cmath>
#include <cstdio>
#include <vector>

using namespace reasampler;

static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
    std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)

static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; }

constexpr double kPi = 3.14159265358979323846;

// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity
// directly (DC of amplitude 1). Root at note 60 by default.
static SampleData dcSample(std::size_t frames, int rootNote = 60) {
    SampleData s;
    s.frames.assign(frames, 1.0f);
    s.rootNote = rootNote;
    return s;
}

// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by
// measuring the played-back period.
static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) {
    SampleData s;
    s.frames.resize(frames);
    for (std::size_t i = 0; i < frames; ++i) {
        s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
                      static_cast<double>(i) / static_cast<double>(frames)));
    }
    s.rootNote = rootNote;
    return s;
}

// An ADSR that stays fully open (level 1) forever while held, so voice output equals
// velocity gain — isolates allocation/repitch/loop tests from envelope shaping.
static AdsrParams flatAdsr() {
    AdsrParams a;
    a.attackFrames = 0;
    a.decayFrames = 0;
    a.sustainLevel = 1.0;
    a.releaseFrames = 0;  // note-off -> instant silence.
    return a;
}

// ---------------------------------------------------------------------------
// 6. Keymap resolution.
// ---------------------------------------------------------------------------

static void testChromaticSingleRoot() {
    Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
    CHECK(km.zones.size() == 1);
    // Every note in 0..127 resolves to the single zone.
    for (int n = 0; n <= 127; ++n) {
        ZoneResolution r = km.resolve(n, 100);
        CHECK(r.matched);
        CHECK(r.zoneIndex == 0);
    }
}

static void testZonedRangesBoundaries() {
    Keymap km;
    km.samples.push_back(dcSample(100, 48));  // low sample
    km.samples.push_back(dcSample(100, 72));  // high sample
    // Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
    // correct zone; a first-match order test would catch an off-by-one.
    km.zones.push_back(KeyZone{36, 59, 48, 0});
    km.zones.push_back(KeyZone{60, 83, 72, 1});

    CHECK(km.resolve(36, 100).matched);
    CHECK(km.resolve(36, 100).zoneIndex == 0);
    CHECK(km.resolve(59, 100).zoneIndex == 0);   // last note of zone 0
    CHECK(km.resolve(60, 100).zoneIndex == 1);   // first note of zone 1
    CHECK(km.resolve(83, 100).zoneIndex == 1);   // last note of zone 1

    // Out of every zone -> defined no-play (not a match, not zone 0).
    CHECK(!km.resolve(35, 100).matched);
    CHECK(!km.resolve(84, 100).matched);
    CHECK(!km.resolve(127, 100).matched);
}

static void testFirstMatchOnOverlap() {
    // Overlapping zones: the earlier zone wins (documented deterministic rule).
    Keymap km;
    km.samples.push_back(dcSample(10, 60));
    km.samples.push_back(dcSample(10, 60));
    km.zones.push_back(KeyZone{0, 127, 60, 0});   // catch-all first
    km.zones.push_back(KeyZone{60, 60, 60, 1});   // shadowed by the catch-all
    CHECK(km.resolve(60, 100).zoneIndex == 0);
}

// ---------------------------------------------------------------------------
// 4. Repitch ratio correctness.
// ---------------------------------------------------------------------------

static void testPitchRatioMath() {
    CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9));   // unity at root
    CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9));   // +1 octave
    CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9));   // -1 octave
    CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9));  // +1 semitone
}

// --- S-VIEW-6 key-tracking ratio math (pure), asserted at 0 / 100 / 200% + off-root. ---
static void testKeyTrackedRatioMath() {
    // 100% (keyTrack == 1.0) is standard 12-tone-ET and BIT-IDENTICAL to pitchRatio: the argument
    // to std::pow is (note-root)*1.0, exact in IEEE-754, so the same call yields the same bits.
    for (int note = 0; note <= 127; ++note) {
        CHECK(keyTrackedRatio(note, 60, 1.0) == pitchRatio(note, 60));  // exact equality, not approx
    }
    CHECK(approx(keyTrackedRatio(72, 60, 1.0), 2.0, 1e-9));   // +1 octave tracked normally
    CHECK(approx(keyTrackedRatio(48, 60, 1.0), 0.5, 1e-9));   // -1 octave tracked normally

    // 0% (keyTrack == 0.0): no tracking. Every key — including off-root ones — plays root pitch.
    CHECK(approx(keyTrackedRatio(60, 60, 0.0), 1.0, 1e-12));  // at root: unity (trivially)
    CHECK(approx(keyTrackedRatio(72, 60, 0.0), 1.0, 1e-12));  // an octave up STILL plays root pitch
    CHECK(approx(keyTrackedRatio(48, 60, 0.0), 1.0, 1e-12));  // an octave down STILL plays root pitch
    CHECK(approx(keyTrackedRatio(67, 60, 0.0), 1.0, 1e-12));  // an off-root 5th STILL plays root pitch

    // 200% (keyTrack == 2.0): double-rate tracking. The semitone offset is doubled, so a +12 key
    // plays as if +24 (two octaves, ratio 4.0), a -12 key as -24 (ratio 0.25).
    CHECK(approx(keyTrackedRatio(72, 60, 2.0), 4.0, 1e-9));   // +12 -> +24 semis -> 4.0
    CHECK(approx(keyTrackedRatio(48, 60, 2.0), 0.25, 1e-9));  // -12 -> -24 semis -> 0.25
    CHECK(approx(keyTrackedRatio(60, 60, 2.0), 1.0, 1e-12));  // root is unity at ANY keyTrack

    // Off-root at 200% for a single semitone: +1 semi -> +2 semis -> 2^(2/12).
    CHECK(approx(keyTrackedRatio(61, 60, 2.0), std::pow(2.0, 2.0 / 12.0), 1e-9));
    // An arbitrary intermediate scalar (50%): +12 key tracks as +6 semis -> 2^(6/12) = sqrt(2).
    CHECK(approx(keyTrackedRatio(72, 60, 0.5), std::pow(2.0, 6.0 / 12.0), 1e-9));
}

// Observe repitch on the rendered signal: a voice played an octave above root should
// advance through the sample twice as fast, so a sine's observed period halves. We
// measure the period by counting the interval between positive-going zero crossings.
static double observedPeriodFrames(const std::vector<AudioSample>& out) {
    std::vector<std::size_t> upCrossings;
    for (std::size_t i = 1; i < out.size(); ++i) {
        if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i);
    }
    if (upCrossings.size() < 2) return 0.0;
    // Average spacing between crossings.
    double sum = 0.0;
    for (std::size_t i = 1; i < upCrossings.size(); ++i) {
        sum += static_cast<double>(upCrossings[i] - upCrossings[i - 1]);
    }
    return sum / static_cast<double>(upCrossings.size() - 1);
}

static void testRepitchObservedPeriod() {
    // A sine of 20 cycles over 8000 frames -> native period 400 frames at unity.
    const std::size_t frames = 8000;
    const double cycles = 20.0;
    const double nativePeriod = static_cast<double>(frames) / cycles;  // 400

    // Unity: played at root, observed period ~= native.
    {
        Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
        VoiceEngine eng(4, km);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, frames);
        double p = observedPeriodFrames(out);
        CHECK(approx(p, nativePeriod, 2.0));
    }
    // +1 octave: advances 2x, observed period halves.
    {
        Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
        VoiceEngine eng(4, km);
        eng.noteOn(72, 127);
        std::vector<AudioSample> out;
        eng.render(out, frames / 2);  // half as many frames covers the whole sample
        double p = observedPeriodFrames(out);
        CHECK(approx(p, nativePeriod / 2.0, 2.0));
    }
    // -1 octave: advances 0.5x, observed period doubles.
    {
        Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
        VoiceEngine eng(4, km);
        eng.noteOn(48, 127);
        std::vector<AudioSample> out;
        eng.render(out, frames);
        double p = observedPeriodFrames(out);
        CHECK(approx(p, nativePeriod * 2.0, 4.0));
    }
}

// --- S-VIEW-6 keyTrack reaches the VARISPEED engine: observed period tracks the scalar. ---
static void testKeyTrackVarispeedObservedPeriod() {
    const std::size_t frames = 8000;
    const double cycles = 20.0;
    const double nativePeriod = static_cast<double>(frames) / cycles;  // 400 at unity

    auto periodAt = [&](int note, double keyTrack) -> double {
        SampleData s = sineSample(frames, cycles, 60);
        s.play.pitchEngine = PitchEngine::Varispeed;
        Keymap km = Keymap::singleSampleChromatic(std::move(s));
        // The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
        km.zones[0].keyTrack = keyTrack;
        VoiceEngine eng(4, km);
        eng.noteOn(note, 127);
        std::vector<AudioSample> out;
        eng.render(out, frames);
        return observedPeriodFrames(out);
    };

    // note 72 (+1 octave). At 100% it plays an octave up (period halves ~200). At 0% it plays at
    // ROOT pitch (period ~native 400 — no tracking). The observed periods must differ by ~2x, which
    // proves the scalar drove the Varispeed read rate.
    const double at100 = periodAt(72, 1.0);
    const double at0 = periodAt(72, 0.0);
    CHECK(approx(at100, nativePeriod / 2.0, 3.0));  // 100%: tracked an octave up
    CHECK(approx(at0, nativePeriod, 3.0));          // 0%: no tracking, plays root pitch
}

// --- S-VIEW-6 keyTrack reaches the PRESERVE engine: at 0% an off-root note collapses to the root
//     shift (unity), producing output identical to playing the root note. Proves keyTrack feeds
//     baseRatio_ -> the Preserve shift amount (not merely the Varispeed read rate). ---
static void testKeyTrackPreserveShiftCollapsesAtZero() {
    const std::size_t frames = 2000;
    const std::size_t window = 512;
    const double cycles = 40.0;

    auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
        SampleData s = sineSample(frames, cycles, 60);
        s.play.pitchEngine = PitchEngine::Preserve;   // Gate, no loop -> runs to sample end
        Keymap km = Keymap::singleSampleChromatic(std::move(s));
        km.zones[0].keyTrack = keyTrack;
        VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
        eng.noteOn(note, 127);
        std::vector<AudioSample> out;
        eng.render(out, frames);
        return out;
    };

    // An off-root note (+7) at keyTrack 0.0 sets the Preserve shift to the root ratio (1.0) — the
    // shifter is pass-through, so the output must be BIT-IDENTICAL to playing the ROOT note (whose
    // offset is 0, also shift 1.0). If keyTrack only touched Varispeed, these would differ.
    const std::vector<AudioSample> offRootNoTrack = renderPreserve(67, 0.0);
    const std::vector<AudioSample> rootRef = renderPreserve(60, 1.0);
    CHECK(offRootNoTrack.size() == rootRef.size());
    bool identical = offRootNoTrack.size() == rootRef.size();
    for (std::size_t i = 0; i < offRootNoTrack.size() && identical; ++i) {
        if (offRootNoTrack[i] != rootRef[i]) identical = false;
    }
    CHECK(identical);  // 0% tracking collapses the Preserve shift to unity, exactly like the root
}

// ---------------------------------------------------------------------------
// 3. ADSR envelope shape vs a known signal.
// ---------------------------------------------------------------------------

static void testAdsrShape() {
    AdsrParams p;
    p.attackFrames = 10;
    p.decayFrames = 10;
    p.sustainLevel = 0.5;
    p.releaseFrames = 10;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();

    // Attack: 0 -> ramps up. Frame 0 == 0, rising each frame.
    double prev = -1.0;
    for (int i = 0; i < 10; ++i) {
        double v = env.tick();
        CHECK(v >= prev);          // monotonic non-decreasing through attack
        CHECK(v >= 0.0 && v <= 1.0);
        prev = v;
    }
    // Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing.
    prev = 2.0;
    for (int i = 0; i < 10; ++i) {
        double v = env.tick();
        CHECK(v <= prev + 1e-9);   // non-increasing through decay
        CHECK(v >= 0.5 - 1e-9);    // never below sustain during decay
        prev = v;
    }
    // Sustain: holds 0.5 indefinitely.
    for (int i = 0; i < 100; ++i) {
        CHECK(approx(env.tick(), 0.5, 1e-9));
    }
    CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);

    // Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0.
    env.noteOff();
    prev = 1.0;
    for (int i = 0; i < 10; ++i) {
        double v = env.tick();
        CHECK(v <= prev + 1e-9);   // non-increasing through release
        prev = v;
    }
    CHECK(env.finished());
    for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12));
}

static void testAdsrReleaseBeforeSustain() {
    // noteOff during the attack ramp releases from the PARTIAL level, not sustain.
    AdsrParams p;
    p.attackFrames = 100;
    p.decayFrames = 10;
    p.sustainLevel = 0.8;
    p.releaseFrames = 20;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();

    // Advance 50 frames into a 100-frame attack -> partial level ~0.5.
    double last = 0.0;
    for (int i = 0; i < 50; ++i) last = env.tick();
    CHECK(last > 0.3 && last < 0.7);        // partway up the attack ramp
    CHECK(env.stage() == AdsrEnvelope::Stage::Attack);

    env.noteOff();
    CHECK(env.stage() == AdsrEnvelope::Stage::Release);
    // First release frame must be at or below the partial level we left off at —
    // NOT jump up to sustain 0.8. This is the release-before-sustain guarantee.
    double firstRelease = env.tick();
    CHECK(firstRelease <= last + 1e-9);
    CHECK(firstRelease < p.sustainLevel);   // proves it did not snap to sustain
    // Decays to zero.
    double prev = firstRelease;
    for (int i = 0; i < 20; ++i) {
        double v = env.tick();
        CHECK(v <= prev + 1e-9);
        prev = v;
    }
    CHECK(env.finished());
}

static void testAdsrZeroAttackDecay() {
    // Zero attack + zero decay -> jumps straight to sustain on the first ticks.
    AdsrParams p;
    p.attackFrames = 0;
    p.decayFrames = 0;
    p.sustainLevel = 0.7;
    p.releaseFrames = 5;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();
    // Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions
    // through the (also zero-length) decay, so by frame 1 the envelope is holding
    // sustain. The peak-at-boundary is the documented single-frame edge, not a bug.
    CHECK(approx(env.tick(), 1.0, 1e-9));   // frame 0: attack peak
    CHECK(approx(env.tick(), 0.7, 1e-9));   // frame 1: sustain
    CHECK(approx(env.tick(), 0.7, 1e-9));
    CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
}

// ---------------------------------------------------------------------------
// 1. Polyphonic allocation + note-off routing.
// ---------------------------------------------------------------------------

static void testPolyphonicAllocation() {
    Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
    VoiceEngine eng(8, km);

    // Four simultaneous notes -> four active voices, each on a distinct voice.
    std::size_t v60 = eng.noteOn(60, 100);
    std::size_t v64 = eng.noteOn(64, 100);
    std::size_t v67 = eng.noteOn(67, 100);
    std::size_t v72 = eng.noteOn(72, 100);
    CHECK(v60 != VoiceEngine::kNoVoice);
    CHECK(eng.activeVoiceCount() == 4);
    CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72);

    // Note-off on 64 releases exactly one voice; with instant release it goes idle
    // after the next render frame.
    eng.noteOff(64);
    std::vector<AudioSample> out;
    eng.render(out, 1);
    CHECK(eng.activeVoiceCount() == 3);

    // The still-held notes keep sounding.
    eng.render(out, 1);
    CHECK(eng.activeVoiceCount() == 3);
}

static void testNoteOffReleasesNewestSameNote() {
    // Prove that noteOff releases the NEWEST (highest startOrder) instance of a
    // re-triggered note, leaving the older voice in sustain.
    //
    // Two voices at distinct velocities so their output is distinguishable:
    //   "first"  (older)  -> velocity 64  -> gain ~0.504 (G_old)
    //   "second" (newer)  -> velocity 127 -> gain  1.0   (G_new)
    //
    // With a DC-1 sample and sustain=1, while both are held:
    //   render sum == G_old + G_new.
    //
    // After noteOff (must release newest), the newer voice enters a short release.
    // Render past releaseFrames: newer voice finishes; only the older voice remains.
    // Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice
    // were released, the older would finish and the remaining sum would equal G_new
    // (1.0 vs ~0.504) — the velocities make the error distinguishable.
    const int velOld = 64;
    const int velNew = 127;
    const double gainOld = velOld / 127.0;   // ~0.504
    const double gainNew = velNew / 127.0;   // 1.0

    SampleData sd = dcSample(100000, 60);
    sd.play.adsr = flatAdsr();
    sd.play.adsr.releaseFrames = 10;   // short but non-zero so voice stays active through release
    Keymap km = Keymap::singleSampleChromatic(sd);
    VoiceEngine eng(8, km);

    std::size_t first  = eng.noteOn(60, velOld);  // older voice, lower gain
    std::size_t second = eng.noteOn(60, velNew);  // newer voice, higher gain
    CHECK(first != second);
    CHECK(eng.activeVoiceCount() == 2);

    // While both are held, combined output equals gainOld + gainNew.
    {
        std::vector<AudioSample> out;
        eng.render(out, 1);
        CHECK(approx(out[0], gainOld + gainNew, 1e-4));
    }

    // Release once — must target the NEWEST voice (second).
    eng.noteOff(60);

    // Render past the release (releaseFrames == 10): newer voice goes Finished.
    std::vector<AudioSample> out;
    eng.render(out, 20);

    // Newer voice must be done; only the older voice remains.
    CHECK(eng.activeVoiceCount() == 1);
    // Tail frames must equal gainOld (~0.504), NOT gainNew (1.0).
    // If the older voice were released instead, the tail would be ~1.0 here.
    for (std::size_t i = 15; i < out.size(); ++i) {
        CHECK(approx(out[i], gainOld, 1e-4));
    }

    // A second note-off releases the remaining older voice.
    eng.noteOff(60);
    eng.render(out, 20);
    CHECK(eng.activeVoiceCount() == 0);
}

static void testOutOfZoneNoteConsumesNoVoice() {
    Keymap km;
    km.samples.push_back(dcSample(100, 60));
    km.zones.push_back(KeyZone{60, 72, 60, 0});
    VoiceEngine eng(4, km);

    std::size_t v = eng.noteOn(30, 100);  // below the only zone
    CHECK(v == VoiceEngine::kNoVoice);
    CHECK(eng.activeVoiceCount() == 0);   // no voice consumed
}

// ---------------------------------------------------------------------------
// 2. Voice stealing at the bound.
// ---------------------------------------------------------------------------

static void testStealsReleasingVoiceFirst() {
    // Long per-zone release so the voice stays active through the release tail. Voice::start reads
    // sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
    SampleData s = dcSample(100000, 60);
    s.play.adsr = flatAdsr();
    s.play.adsr.releaseFrames = 100000;  // long release so a released voice stays "active"
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(2, km);

    std::size_t vA = eng.noteOn(60, 100);  // startOrder 1
    std::size_t vB = eng.noteOn(62, 100);  // startOrder 2
    CHECK(eng.activeVoiceCount() == 2);

    // Release the NEWER voice (62) — it becomes the only releasing voice.
    eng.noteOff(62);
    std::vector<AudioSample> out;
    eng.render(out, 1);
    CHECK(eng.activeVoiceCount() == 2);  // both still ringing (long release)

    // A new note with the pool full must steal the RELEASING voice (vB), not the
    // older held voice (vA) — release-first policy.
    std::size_t vC = eng.noteOn(64, 100);
    CHECK(vC == vB);
    CHECK(eng.activeVoiceCount() == 2);
}

static void testStealsOldestWhenNoneReleasing() {
    // Long per-zone release — placed on SampleData.play.adsr per the S12 fix.
    SampleData s = dcSample(100000, 60);
    s.play.adsr = flatAdsr();
    s.play.adsr.releaseFrames = 100000;
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(2, km);

    std::size_t vA = eng.noteOn(60, 100);  // startOrder 1 (oldest)
    std::size_t vB = eng.noteOn(62, 100);  // startOrder 2
    CHECK(vA != vB);

    // No voice released; both held. A new note steals the OLDEST (vA).
    std::size_t vC = eng.noteOn(64, 100);
    CHECK(vC == vA);
    CHECK(eng.activeVoiceCount() == 2);

    // The stolen voice now carries note 64; a note-off on 60 (the stolen-away note)
    // finds nothing to release.
    std::size_t before = eng.activeVoiceCount();
    eng.noteOff(60);
    std::vector<AudioSample> out;
    eng.render(out, 1);
    CHECK(eng.activeVoiceCount() == before);  // 60 no longer exists; no-op
}

// ---------------------------------------------------------------------------
// 5. Loop-point-aware sustain.
// ---------------------------------------------------------------------------

static void testLoopSustainSeamless() {
    // A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop
    // region of value 0.5. Held far past the sample end, the voice must keep emitting
    // the loop region (0.5) rather than going silent.
    SampleData s;
    s.frames.resize(40);
    for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) / 20.0f;  // attack
    for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f;                          // loop body
    s.rootNote = 60;
    s.loop.hasLoop = true;
    s.loop.start = 20;
    s.loop.end = 40;

    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity ratio, full velocity

    std::vector<AudioSample> out;
    eng.render(out, 200);  // 5x the sample length

    // Voice is still active (looping), not exhausted.
    CHECK(eng.activeVoiceCount() == 1);
    // Frames well past the loop start must sit at the loop body value 0.5.
    for (std::size_t i = 60; i < out.size(); ++i) {
        CHECK(approx(out[i], 0.5, 1e-4));
    }
}

static void testZeroLengthLoopGoesSilent() {
    // A zero-length loop (start == end) is the "no sustain" marker: the note runs off
    // the sample end and the voice goes idle, rather than spinning on an empty span.
    SampleData s = dcSample(50, 60);   // 50 frames of 1.0
    s.loop.hasLoop = true;
    s.loop.start = 25;
    s.loop.end = 25;                   // zero length
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);

    std::vector<AudioSample> out;
    eng.render(out, 100);              // past the 50-frame end
    // After frame ~50 the voice should have gone idle (no loop to sustain it).
    CHECK(eng.activeVoiceCount() == 0);
    // Tail frames are silent.
    for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
}

static void testSingleFrameLoop() {
    // A loop of exactly one frame [start, start+1) — the narrowest valid loop.
    // The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer
    // readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing.
    SampleData s;
    s.frames.resize(10);
    for (int i = 0; i < 10; ++i) s.frames[i] = static_cast<float>(i) * 0.1f;
    s.rootNote = 60;
    s.loop.hasLoop = true;
    s.loop.start = 5;
    s.loop.end = 6;  // single-frame loop: [5, 6)

    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity ratio, full velocity

    std::vector<AudioSample> out;
    eng.render(out, 50);  // well past the sample end

    // Voice must still be active — the single-frame loop keeps it alive.
    CHECK(eng.activeVoiceCount() == 1);
    // Every frame from the loop-start onward must be the value of frame 5 (0.5).
    for (std::size_t i = 10; i < out.size(); ++i) {
        CHECK(approx(out[i], 0.5, 1e-4));
    }
}

static void testAbsentLoopGoesSilent() {
    // No loop at all: held note runs off the end and goes idle (same as zero-length).
    SampleData s = dcSample(50, 60);
    // s.loop.hasLoop stays false.
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 100);
    CHECK(eng.activeVoiceCount() == 0);
    for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
}

// ---------------------------------------------------------------------------
// start point (S11): the voice's initial read position is SampleData::startFrame.
// ---------------------------------------------------------------------------

static void testStartFrameOffsetsInitialRead() {
    // A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the
    // read position. startFrame = 30 -> the first output frame reads frame 30 (0.30).
    SampleData s;
    s.frames.resize(100);
    for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
    s.rootNote = 60;
    s.startFrame = 30;
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity ratio, full velocity, flat gain
    std::vector<AudioSample> out;
    eng.render(out, 3);
    CHECK(approx(out[0], 0.30, 1e-4));  // starts at frame 30, not 0
    CHECK(approx(out[1], 0.31, 1e-4));  // advances by unity ratio
    CHECK(approx(out[2], 0.32, 1e-4));
}

static void testStartFrameZeroIsUnchanged() {
    // startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0.
    SampleData s;
    s.frames.resize(20);
    for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
    s.rootNote = 60;  // startFrame stays 0
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 1);
    CHECK(approx(out[0], 0.0, 1e-6));  // frame 0
}

static void testStartFrameOutOfRangeClampsToZero() {
    // A start point at/past the sample end degrades to frame 0 (play from the top), never an
    // out-of-bounds read that would start the voice already exhausted.
    SampleData s = dcSample(10, 60);          // 10 frames of 1.0
    s.startFrame = 10;                         // == frameCount: out of range
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 5);
    // Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle.
    CHECK(eng.activeVoiceCount() == 1);
    CHECK(approx(out[0], 1.0, 1e-4));
}

static void testStartFrameWithLoop() {
    // Start point and loop compose: begin reading mid-sample, then sustain the loop region.
    SampleData s;
    s.frames.resize(40);
    for (int i = 0; i < 40; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
    for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f;   // loop body
    s.rootNote = 60;
    s.startFrame = 10;                                   // begin at frame 10
    s.loop.hasLoop = true;
    s.loop.start = 20;
    s.loop.end = 40;
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 200);
    CHECK(approx(out[0], 0.10, 1e-4));       // started at frame 10
    CHECK(eng.activeVoiceCount() == 1);      // loop sustains it
    for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4));
}

static void testStartAfterLoopEndWrapsIntoLoop() {
    // Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount),
    // the voice's initial read head is past the loop end. The wrap-while in renderFrame must
    // pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from
    // somewhere inside the loop rather than running off the sample end silently.
    //
    // Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40).
    // loop body is a constant 0.5 so every frame inside it reads 0.5.
    // After wrap: readPos starts inside [20, 40), first output frame == 0.5.
    // Voice must stay active (loop sustains it) and emit the loop value, NOT go silent.
    SampleData s;
    s.frames.resize(100, 0.0f);
    for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f;  // loop body
    s.rootNote = 60;
    s.startFrame = 60;  // > loop.end (40), < frameCount (100)
    s.loop.hasLoop = true;
    s.loop.start = 20;
    s.loop.end   = 40;

    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity ratio, full velocity

    std::vector<AudioSample> out;
    eng.render(out, 50);

    // Voice must still be active — the usable loop keeps it alive indefinitely.
    CHECK(eng.activeVoiceCount() == 1);
    // Every frame after the first wrap must read 0.5 (the loop body). We skip the very
    // first frame because the fractional-position wrap lands somewhere in [20,40) and the
    // exact offset depends on how many loop lengths fit into 60; what matters is that the
    // voice is alive and emitting the loop value, not 0.0 (pre-loop region).
    for (std::size_t i = 5; i < out.size(); ++i) {
        CHECK(approx(out[i], 0.5, 1e-4));
    }
}

// ---------------------------------------------------------------------------
// velocity -> volume.
// ---------------------------------------------------------------------------

static void testVelocityToVolume() {
    Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));  // DC 1.0
    // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
    // rendered value is exactly velocity/127 on a DC-1 sample).
    {
        VoiceEngine eng(1, km);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, 1);
        CHECK(approx(out[0], 1.0, 1e-4));
    }
    {
        VoiceEngine eng(1, km);
        eng.noteOn(60, 64);
        std::vector<AudioSample> out;
        eng.render(out, 1);
        CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
    }
    {
        VoiceEngine eng(1, km);
        eng.noteOn(60, 1);
        std::vector<AudioSample> out;
        eng.render(out, 1);
        CHECK(approx(out[0], 1.0 / 127.0, 1e-4));
    }
}

// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
    Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));  // DC 1.0
    VoiceEngine eng(4, km);
    eng.noteOn(60, 127);   // gain 1.0
    eng.noteOn(60, 127);   // gain 1.0 (second voice, same note)
    std::vector<AudioSample> out;
    eng.render(out, 1);
    CHECK(approx(out[0], 2.0, 1e-4));  // both voices sum
}

// ---------------------------------------------------------------------------
// 7. Stereo channel dimension (S7).
// ---------------------------------------------------------------------------

// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render
// must keep them distinct; a mono render (channel 0 only) sees L.
static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) {
    SampleData s;
    s.frames.assign(frames, l);
    s.framesR.assign(frames, r);
    s.rootNote = rootNote;
    return s;
}

static void testChannelCount() {
    // Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2.
    CHECK(dcSample(10, 60).channelCount() == 1);
    CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2);
    // A mismatched framesR length is treated as mono (a bad pair never half-plays).
    SampleData bad = dcSample(10, 60);
    bad.framesR.assign(5, 0.5f);  // wrong length
    CHECK(bad.channelCount() == 1);
}

static void testStereoRenderKeepsChannelsDistinct() {
    // A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
    // scaled by velocity (full here). If the engine copied L to both channels the R check fails.
    Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);

    std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
    eng.render(left.data(), right.data(), 8);
    for (std::size_t i = 0; i < 8; ++i) {
        CHECK(approx(left[i], 1.0, 1e-4));    // channel 0
        CHECK(approx(right[i], -1.0, 1e-4));  // channel 1 — distinct, NOT a copy of L
    }
}

static void testMonoSamplePlaysDualMonoInStereo() {
    // A MONO sample rendered through the stereo path plays dual-mono: both channels equal
    // (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
    Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));  // mono, DC 1.0
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
    eng.render(left.data(), right.data(), 8);
    for (std::size_t i = 0; i < 8; ++i) {
        CHECK(approx(left[i], 1.0, 1e-4));
        CHECK(approx(right[i], 1.0, 1e-4));  // R == L (dual-mono), not 0
    }
}

static void testMonoRenderUnchangedByStereoData() {
    // Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
    // whether or not a second channel is present. A stereo sample rendered mono == its L channel.
    Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
    VoiceEngine engS(1, kmS);
    engS.noteOn(60, 127);
    std::vector<AudioSample> mono;
    engS.render(mono, 8);  // the mono overload
    for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4));  // == L, ignores R
}

static void testStereoRenderAdvancesLikeMonoRepitch() {
    // The stereo path must advance the read head by the SAME per-frame ratio as the mono path,
    // so repitch is identical. Play a stereo sine (both channels the same signal) an octave up
    // and confirm the observed period halves — the mono repitch assertion, on the stereo path.
    const std::size_t frames = 8000;
    const double cycles = 20.0;
    const double nativePeriod = static_cast<double>(frames) / cycles;  // 400
    SampleData s;
    s.frames.resize(frames);
    s.framesR.resize(frames);
    for (std::size_t i = 0; i < frames; ++i) {
        const float v = static_cast<float>(std::sin(2.0 * kPi * cycles *
                        static_cast<double>(i) / static_cast<double>(frames)));
        s.frames[i] = v;
        s.framesR[i] = v;
    }
    s.rootNote = 60;
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(4, km);
    eng.noteOn(72, 127);  // +1 octave
    std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
    eng.render(left.data(), right.data(), frames / 2);
    CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0));
    CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0));  // R repitches identically
}

static void testStereoRenderSumsVoicesPerChannel() {
    // Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
    Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
    VoiceEngine eng(4, km);
    eng.noteOn(60, 127);
    eng.noteOn(60, 127);  // second voice, same note
    std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
    eng.render(left.data(), right.data(), 1);
    CHECK(approx(left[0], 1.0, 1e-4));    // 0.5 + 0.5
    CHECK(approx(right[0], -1.0, 1e-4));  // -0.5 + -0.5
}

static void testStereoRenderNullBufferIsNoOp() {
    Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> buf(4, 0.f);
    eng.render(nullptr, buf.data(), 4);   // null left -> no-op, no crash
    eng.render(buf.data(), nullptr, 4);   // null right -> no-op
    for (float v : buf) CHECK(approx(v, 0.0, 1e-9));  // untouched
}

static void testStereoStartFrameLoopShareOneReadHead() {
    // S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH
    // channels from the SAME single read head — one offset, one loop wrap, applied to L and R
    // identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset
    // apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave
    // L and R independent heads, the constant L->R offset would break at the start jump or the
    // loop seam.
    SampleData s;
    s.frames.resize(40);
    s.framesR.resize(40);
    for (int i = 0; i < 40; ++i) {
        s.frames[i]  = static_cast<float>(i) * 0.01f;         // L: 0.00 .. 0.39
        s.framesR[i] = static_cast<float>(i) * 0.01f + 0.5f;  // R: L + 0.5, everywhere
    }
    s.rootNote = 60;
    s.startFrame = 10;          // begin BOTH channels at frame 10
    s.loop.hasLoop = true;
    s.loop.start = 20;
    s.loop.end   = 30;          // loop [20,30): frames 20..29
    CHECK(s.channelCount() == 2);
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);        // unity ratio, full velocity, flat gain

    std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
    eng.render(left.data(), right.data(), 200);

    // First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset.
    CHECK(approx(left[0], 0.10, 1e-4));
    CHECK(approx(right[0], 0.60, 1e-4));
    // The loop sustains the voice indefinitely.
    CHECK(eng.activeVoiceCount() == 1);
    // At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index
    // (one read head) through the start jump and every loop wrap. A per-channel head drift would
    // break this invariant at the seam.
    for (std::size_t i = 0; i < left.size(); ++i) {
        CHECK(approx(right[i] - left[i], 0.5, 1e-4));
    }
    // Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio
    // frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the
    // loop region on both channels, never running off the sample end.
    for (std::size_t i = 15; i < left.size(); ++i) {
        CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4);
    }
}

// ===========================================================================
// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity).
// ===========================================================================

// --- AHDSR hold stage vs a known signal. ---
static void testAhdsrHoldStageShape() {
    // Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f),
    // decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames.
    AdsrParams p;
    p.attackFrames = 5;
    p.holdFrames = 8;
    p.decayFrames = 5;
    p.sustainLevel = 0.5;
    p.releaseFrames = 5;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();

    for (int i = 0; i < 5; ++i) env.tick();  // consume Attack (ends at 1.0)
    // The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold.
    for (int i = 0; i < 8; ++i) {
        CHECK(env.stage() == AdsrEnvelope::Stage::Hold);
        CHECK(approx(env.tick(), 1.0, 1e-9));
    }
    // Then Decay begins, falling from 1.0 toward sustain 0.5.
    CHECK(env.stage() == AdsrEnvelope::Stage::Decay);
    double v = env.tick();
    CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9);
}

// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). ---
static void testAhdsrHoldZeroEqualsAdsr() {
    // The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame.
    // Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be
    // tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output:
    //   Attack:  0/4, 1/4, 2/4, 3/4          (ticks 0..3, level rising 0 -> 0.75)
    //   Decay:   1.0, then 1.0+(0.5-1)*t for t=1/4..3/4  (ticks 4..7: 1.0, 0.875, 0.75, 0.625)
    //   Sustain: 0.5 forever                 (tick 8+)
    AdsrParams p;
    p.attackFrames = 4;
    p.holdFrames = 0;      // the degenerate — must NOT insert an extra unity frame
    p.decayFrames = 4;
    p.sustainLevel = 0.5;
    p.releaseFrames = 4;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();
    const double expected[] = {0.0, 0.25, 0.5, 0.75,   // attack
                               1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0)
                               0.5, 0.5, 0.5};           // sustain
    for (double e : expected) CHECK(approx(env.tick(), e, 1e-9));
    CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);  // reached sustain at the SAME tick count
}

// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
static SampleData triggerSample(std::size_t frames, double lengthFraction,
                                std::int64_t fadeIn, std::int64_t fadeOut,
                                std::int64_t startFrame = 0) {
    SampleData s = dcSample(frames, 60);
    s.startFrame = startFrame;
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = PitchEngine::Varispeed;  // isolate amp shape from pitch
    s.play.trigger.lengthFraction = lengthFraction;
    s.play.trigger.fadeInFrames = fadeIn;
    s.play.trigger.fadeOutFrames = fadeOut;
    return s;
}

// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
static void testTriggerLengthFractionFrames() {
    // 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
    Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity ratio
    std::vector<AudioSample> out;
    eng.render(out, 200);
    // First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed.
    for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f);
    for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
    CHECK(eng.activeVoiceCount() == 0);  // ran off playEnd
}

// --- Trigger start point: %-length measured from the start offset. ---
static void testTriggerLengthWithStart() {
    // 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
    Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 200);
    for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f);
    for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
    CHECK(eng.activeVoiceCount() == 0);
}

// --- Trigger fade-in / fade-out ramp shape (equal-power default). ---
static void testTriggerFadeShape() {
    // 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
    // between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
    Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 120);
    CHECK(approx(out[0], 0.0, 1e-3));          // fade-in starts at 0
    // Fade-in monotonic non-decreasing.
    for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4);
    // Unity plateau in the middle.
    for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3));
    // Fade-out monotonic non-increasing over [80,100).
    for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4);
    // Past playEnd = silence.
    for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6));
}

// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). ---
static void testTriggerEdgeCases() {
    // %=0: zero play length -> voice frees at once, no sound.
    {
        Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
        VoiceEngine eng(1, km);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, 50);
        for (float v : out) CHECK(approx(v, 0.0, 1e-6));
        CHECK(eng.activeVoiceCount() == 0);
    }
    // Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
    {
        // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
        Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
        VoiceEngine eng(1, km);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, 50);
        for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4);
        CHECK(eng.activeVoiceCount() == 0);
    }
    // %=100 plays the full post-start span.
    {
        Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
        VoiceEngine eng(1, km);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, 80);
        for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f);
        for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6));
    }
}

// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
    Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 10);
    eng.noteOff(60);                 // must be a NO-OP in Trigger
    CHECK(eng.activeVoiceCount() == 1);   // still sounding after note-off
    eng.render(out, 200);
    // It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off).
    for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f);
    for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6));
    CHECK(eng.activeVoiceCount() == 0);   // frees on its own playEnd, not on note-off
}

// ===========================================================================
// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical).
// ===========================================================================

// Render one note to completion (or `maxFrames`) and return the frame count at which the voice
// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off.
static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) {
    std::vector<AudioSample> out;
    std::size_t len = 0;
    for (std::size_t f = 0; f < maxFrames; ++f) {
        eng.render(out, 1);
        if (eng.activeVoiceCount() > 0) len = f + 1;
        else break;
    }
    return len;
}

// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable
// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from
// Gate's release tail.
static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) {
    SampleData s = dcSample(frames, 60);
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = PitchEngine::Preserve;
    s.play.trigger.lengthFraction = lengthFraction;
    return s;
}

// --- Preserve duration invariance: same note length across +/-12 semitones. ---
static void testPreserveDurationInvariance() {
    // A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames
    // regardless of transpose (duration held). Under Varispeed an octave up would halve it.
    const std::size_t frames = 1000;
    const std::size_t window = 512;  // pre-size the shifters

    auto lengthAt = [&](int note) -> std::size_t {
        Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
        VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
        eng.noteOn(note, 127);
        return soundingLength(eng, 4000);
    };

    const std::size_t atRoot = lengthAt(60);
    const std::size_t atUp = lengthAt(72);    // +12
    const std::size_t atDown = lengthAt(48);  // -12
    // All three within a small tolerance of the source length (Preserve holds duration). The
    // tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
    CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
    CHECK(atUp >= frames - 20 && atUp <= frames + 20);
    CHECK(atDown >= frames - 20 && atDown <= frames + 20);
    // The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
    CHECK(atUp > frames / 2 + 200);   // an octave up did NOT halve the duration (Varispeed would)
    CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
}

// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). ---
static void testVarispeedStillCouplesDuration() {
    // A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled).
    auto lengthAt = [&](int note) -> std::size_t {
        SampleData s = dcSample(1000, 60);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.trigger.lengthFraction = 1.0;
        Keymap km = Keymap::singleSampleChromatic(std::move(s));
        VoiceEngine eng(1, km);
        eng.noteOn(note, 127);
        return soundingLength(eng, 4000);
    };
    const std::size_t atRoot = lengthAt(60);
    const std::size_t atUp = lengthAt(72);
    CHECK(approx(static_cast<double>(atUp), static_cast<double>(atRoot) / 2.0, 30.0));
}

// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). ---
static void testPitchEnvOffBitIdentical() {
    // Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their
    // rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16
    // "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift.
    const std::size_t n = 4000;
    auto renderOne = [&](bool withDisabledEnv) -> std::vector<AudioSample> {
        SampleData s = sineSample(n, 20.0, 60);
        s.play.pitchEngine = PitchEngine::Varispeed;
        if (withDisabledEnv) {
            s.play.pitchEnv.enabled = false;         // explicitly disabled (offset always 0)
            s.play.pitchEnv.peakSemitones = 12.0;    // a depth that WOULD matter if enabled
            s.play.pitchEnv.attackFrames = 0;
            s.play.pitchEnv.decayFrames = 500;
        }
        Keymap km = Keymap::singleSampleChromatic(std::move(s));
        VoiceEngine eng(1, km);
        eng.noteOn(67, 127);  // a transposed note so ratio != 1 (exercises the ratio path)
        std::vector<AudioSample> out;
        eng.render(out, n);
        return out;
    };
    const std::vector<AudioSample> a = renderOne(false);
    const std::vector<AudioSample> b = renderOne(true);
    CHECK(a.size() == b.size());
    bool identical = a.size() == b.size();
    for (std::size_t i = 0; i < a.size() && identical; ++i) {
        if (a[i] != b[i]) identical = false;
    }
    CHECK(identical);  // disabled pitch env produces the EXACT same samples (no modulation)
}

// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. ---
static void testPitchEnvOnBendsVarispeed() {
    // Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and
    // settles. Observe the read advancing FASTER at the start (period shorter early) than late.
    const std::size_t n = 8000;
    SampleData s = sineSample(n, 40.0, 60);
    s.play.pitchEngine = PitchEngine::Varispeed;
    s.play.pitchEnv.enabled = true;
    s.play.pitchEnv.attackFrames = 0;         // start at the peak
    s.play.pitchEnv.decayFrames = 3000;       // glide to base over 3000 frames
    s.play.pitchEnv.peakSemitones = 12.0;     // +1 octave at t=0
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // at root -> base ratio 1.0; the env supplies the bend
    std::vector<AudioSample> out;
    eng.render(out, 4000);
    // Early period (heavily transposed up) should be shorter than the late period (settled).
    std::vector<AudioSample> early(out.begin(), out.begin() + 800);
    std::vector<AudioSample> late(out.begin() + 3200, out.begin() + 4000);
    const double pe = observedPeriodFrames(early);
    const double pl = observedPeriodFrames(late);
    CHECK(pe > 0.0 && pl > 0.0);
    CHECK(pe < pl);  // pitch dropped over time (period lengthened) -> the AD env bent the pitch
}

// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). ---
static void testPreserveGateStereoLoopComposes() {
    // A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH
    // channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing.
    SampleData s;
    const std::size_t frames = 400;
    s.frames.resize(frames);
    s.framesR.resize(frames);
    for (std::size_t i = 0; i < frames; ++i) {
        const float v = static_cast<float>(std::sin(2.0 * kPi * 8.0 *
                        static_cast<double>(i) / static_cast<double>(frames)));
        s.frames[i] = v;
        s.framesR[i] = v * 0.5f;  // R is a distinct (half-amplitude) channel
    }
    s.rootNote = 60;
    s.loop.hasLoop = true;
    s.loop.start = 100;
    s.loop.end = 300;
    s.play.playMode = PlayMode::Gate;
    s.play.pitchEngine = PitchEngine::Preserve;
    CHECK(s.channelCount() == 2);
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km, 0, 512);
    eng.noteOn(67, 127);  // transposed up a fifth under Preserve (duration held)
    std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
    eng.render(left.data(), right.data(), 2000);
    // The loop sustains the voice well past the sample length (400 frames) -> still active.
    CHECK(eng.activeVoiceCount() == 1);
    // Both channels carry signal (some frame has non-trivial magnitude on each).
    double maxL = 0.0, maxR = 0.0;
    for (std::size_t i = 600; i < 2000; ++i) {
        if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]);
        if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]);
    }
    CHECK(maxL > 0.05);
    CHECK(maxR > 0.02);  // R present (half amplitude), distinct from L -> stereo preserved
}

// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
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(60, 127) != VoiceEngine::kNoVoice);   // 1st Preserve voice
    CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice);   // 2nd Preserve voice (at the cap)
    CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice);   // 3rd DROPPED by the Preserve cap
    CHECK(eng.activeVoiceCount() == 2);
}

// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
//
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.

// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
// 1.0 at frame 0. This is the load-bearing proof.
static void testPerZoneAdsrReachesVoiceEnvelope() {
    SampleData s = dcSample(500, 60);
    // Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
    s.play.adsr.attackFrames  = 10;
    s.play.adsr.holdFrames    = 0;
    s.play.adsr.decayFrames   = 0;
    s.play.adsr.sustainLevel  = 1.0;
    s.play.adsr.releaseFrames = 0;
    s.play.pitchEngine = PitchEngine::Varispeed;  // isolate from pitch engine machinery
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);  // unity pitch, full velocity -> gain 1.0
    std::vector<AudioSample> out;
    eng.render(out, 20);
    // Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
    CHECK(approx(out[0], 0.0, 1e-9));    // env still at bottom of ramp
    // Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
    CHECK(out[9] < 1.0 - 1e-9);
    // Frame 10+: attack complete, sustain at 1.0.
    CHECK(approx(out[10], 1.0, 1e-9));
    CHECK(approx(out[19], 1.0, 1e-9));
}

// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path.
// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must
// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the
// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before.
static void testZeroAdsrIsInstantSustain() {
    SampleData s = dcSample(20, 60);
    // Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
    s.play.adsr = AdsrParams{};
    s.play.pitchEngine = PitchEngine::Varispeed;
    Keymap km = Keymap::singleSampleChromatic(std::move(s));
    VoiceEngine eng(1, km);
    eng.noteOn(60, 127);
    std::vector<AudioSample> out;
    eng.render(out, 5);
    // All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level.
    for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
}

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();
    testVelocityToVolume();
    testPolyphonyMixesAdditively();
    testChannelCount();
    testStereoRenderKeepsChannelsDistinct();
    testMonoSamplePlaysDualMonoInStereo();
    testMonoRenderUnchangedByStereoData();
    testStereoRenderAdvancesLikeMonoRepitch();
    testStereoRenderSumsVoicesPerChannel();
    testStereoRenderNullBufferIsNoOp();
    testStereoStartFrameLoopShareOneReadHead();

    // S15 — sampling modes.
    testAhdsrHoldStageShape();
    testAhdsrHoldZeroEqualsAdsr();
    testTriggerLengthFractionFrames();
    testTriggerLengthWithStart();
    testTriggerFadeShape();
    testTriggerEdgeCases();
    testTriggerIgnoresNoteOff();

    // S16 — pitch engine + pitch envelope.
    testPreserveDurationInvariance();
    testVarispeedStillCouplesDuration();
    testPitchEnvOffBitIdentical();
    testPitchEnvOnBendsVarispeed();
    testPreserveGateStereoLoopComposes();
    testPreserveVoiceCap();

    // S12 review fix — per-zone A/D/S/R reaches the voice envelope.
    testPerZoneAdsrReachesVoiceEnvelope();
    testZeroAdsrIsInstantSustain();

    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;
}
