// Standalone tests for the STAGED ENVELOPE system in the pure engine — no VST3, no REAPER, no
// framework. The engine's other seams are covered by sampler_core_tests (allocation, repitch,
// loops), sampler_filter_tests (the filter in the voice path) and live_delivery_tests (what a
// published block does to a sounding voice); this file covers what shape the envelopes have.
//
// Covers: the LINEAR NEUTRAL (exponent 1.0 reproduces the pre-curve evaluation bit for bit on
// every sloped stage of all three envelopes); the exponent sweep across the full domain
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
// mode's stage values surviving the other; the terminal ring-out under Preserve — its shape,
// what it must NOT hold up (the Preserve cap, mono legato), and the Varispeed render it must
// leave alone; and the pitch envelope's span domain under a transposed Varispeed voice.
//
// The migration contour of the retired fade pair is asserted where the lift lives, in
// component_state_io_tests.

#include "../src/core/instrument/engine/voice_engine.h"

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>

using namespace reasampler;
using namespace reasampler::instrument::engine;

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

constexpr double kPi = 3.14159265358979323846;

static SampleData dcSample(std::size_t frames, int rootNote = 60) {
    SampleData s;
    s.frames.assign(frames, 1.0f);
    s.rootNote = rootNote;
    s.sampleRate = 48000;
    return s;
}

// --- The linear neutral -------------------------------------------------------

// The migration bar, at the evaluator: with every exponent at 1.0 each sloped stage emits
// EXACTLY the closed-form linear value the pre-curve engine emitted. Bit-identical, not close:
// a hair of drift here is a project reopening with a different sound.
static void testNeutralExponentReproducesTheLinearEvaluationExactly() {
    // AHDSR attack, decay and release, each measured over its whole span.
    AdsrParams p;
    p.attackFrames = 400;
    p.holdFrames = 0;
    p.decayFrames = 500;
    p.sustainLevel = 0.25;
    p.releaseFrames = 300;
    AdsrEnvelope env;
    env.configure(p);
    env.noteOn();
    for (int i = 0; i < 400; ++i) {
        CHECK(env.tick() == static_cast<double>(i) / 400.0);
    }
    for (int i = 0; i < 500; ++i) {
        CHECK(env.tick() == 1.0 + (0.25 - 1.0) * (static_cast<double>(i) / 500.0));
    }
    CHECK(env.tick() == 0.25);  // sustain
    env.noteOff();
    for (int i = 0; i < 300; ++i) {
        CHECK(env.tick() == 0.25 * (1.0 - static_cast<double>(i) / 300.0));
    }

    // The AHD's two sloped stages, against the same closed forms.
    AhdParams a;
    a.attackFrames = 200;
    a.decayFrames = 300;
    a.holdFraction = 0.0;
    AhdEnvelope ahd;
    ahd.configure(1000, a);
    for (int i = 0; i < 200; ++i) {
        CHECK(ahd.amplitudeAt(i) == static_cast<double>(i) / 200.0);
    }
    for (int i = 0; i < 300; ++i) {
        CHECK(ahd.amplitudeAt(200 + i) == 1.0 - static_cast<double>(i) / 300.0);
    }

    // The pitch envelope's two, scaled by the depth.
    PitchEnvParams pe;
    pe.enabled = true;
    pe.peakSemitones = 12.0;
    pe.shape.attackFrames = 200;
    pe.shape.decayFrames = 300;
    pe.shape.holdFraction = 0.0;
    PitchEnvelope pitch;
    pitch.configure(1000, pe);
    pitch.noteOn();
    for (int i = 0; i < 200; ++i) {
        CHECK(pitch.tick() == 12.0 * (static_cast<double>(i) / 200.0));
    }
    for (int i = 0; i < 300; ++i) {
        CHECK(pitch.tick() == 12.0 * (1.0 - static_cast<double>(i) / 300.0));
    }
}

// --- The exponent sweep -------------------------------------------------------

// The whole domain including both endpoints: every emitted value finite, inside the stage's
// own endpoint levels, and monotone in the stage's direction. An exponent can reshape a stage
// but never make it overshoot or wander.
static void testExponentSweepStaysFiniteMonotoneAndInRange() {
    const double exps[] = {util::kCurveMin, 0.3, 0.7, 1.0, 2.0, 5.0, util::kCurveMax};
    for (double e : exps) {
        AdsrParams p;
        p.attackFrames = 256;
        p.decayFrames = 256;
        p.sustainLevel = 0.3;
        p.releaseFrames = 256;
        p.attackCurve = e;
        p.decayCurve = e;
        p.releaseCurve = e;
        AdsrEnvelope env;
        env.configure(p);
        env.noteOn();

        double prev = -1.0;
        for (int i = 0; i < 256; ++i) {  // attack: rises 0 -> 1
            const double v = env.tick();
            CHECK(std::isfinite(v));
            CHECK(v >= 0.0 && v <= 1.0);
            CHECK(v >= prev);
            prev = v;
        }
        prev = 2.0;
        for (int i = 0; i < 256; ++i) {  // decay: falls 1 -> sustain, never below it
            const double v = env.tick();
            CHECK(std::isfinite(v));
            CHECK(v >= 0.3 - 1e-12 && v <= 1.0 + 1e-12);
            CHECK(v <= prev);
            prev = v;
        }
        env.tick();  // sustain
        env.noteOff();
        prev = 2.0;
        for (int i = 0; i < 256; ++i) {  // release: falls to 0, never below
            const double v = env.tick();
            CHECK(std::isfinite(v));
            CHECK(v >= -1e-12 && v <= 0.3 + 1e-12);
            CHECK(v <= prev);
            prev = v;
        }

        // The AHD's own two stages under the same exponent.
        AhdParams a;
        a.attackFrames = 256;
        a.decayFrames = 256;
        a.holdFraction = 0.0;
        a.attackCurve = e;
        a.decayCurve = e;
        AhdEnvelope ahd;
        ahd.configure(1000, a);
        prev = -1.0;
        for (int i = 0; i < 256; ++i) {
            const double v = ahd.amplitudeAt(i);
            CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v >= prev);
            prev = v;
        }
        prev = 2.0;
        for (int i = 0; i < 256; ++i) {
            const double v = ahd.amplitudeAt(256 + i);
            CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v <= prev);
            prev = v;
        }
    }
}

// The exponent has to be AUDIBLE, not merely stored: the same stage at two exponents renders
// measurably different levels at the same position.
static void testExponentActuallyReshapesTheStage() {
    AhdParams a;
    a.attackFrames = 1000;
    a.holdFraction = 0.0;
    AhdEnvelope steep, shallow;
    a.attackCurve = 8.0;
    steep.configure(2000, a);
    a.attackCurve = 0.15;
    shallow.configure(2000, a);
    CHECK(steep.amplitudeAt(500) < 0.05);
    CHECK(shallow.amplitudeAt(500) > 0.85);
    CHECK(shallow.amplitudeAt(500) - steep.amplitudeAt(500) > 0.5);
}

// --- The AHD span split -------------------------------------------------------

// The combined-time bound, swept across the full domains: no (attack, decay, hold-fraction)
// triple can push the sum past the span. The property is structural — Hold is a fraction of
// what is LEFT — so there is no clamp on the sum for a case to slip past.
static void testAhdSumNeverExceedsTheSpanForAnyTriple() {
    const std::int64_t span = 1000;
    for (std::int64_t a = 0; a <= 2000; a += 125) {
        for (std::int64_t d = 0; d <= 2000; d += 125) {
            for (int f = 0; f <= 10; ++f) {
                AhdParams p;
                p.attackFrames = a;
                p.decayFrames = d;
                p.holdFraction = f * 0.1;
                const AhdSpan s = fitAhd(span, p);
                CHECK(s.attack >= 0 && s.hold >= 0 && s.decay >= 0);
                CHECK(s.total == s.attack + s.hold + s.decay);
                CHECK(s.total <= span);
                // And the envelope itself is silent at and past the fitted total.
                AhdEnvelope e;
                e.configure(span, p);
                CHECK(e.amplitudeAt(static_cast<double>(s.total)) == 0.0);
            }
        }
    }
}

static void testHoldFractionEndpoints() {
    AhdParams p;
    p.attackFrames = 100;
    p.decayFrames = 200;

    p.holdFraction = 0.0;
    const AhdSpan none = fitAhd(1000, p);
    CHECK(none.hold == 0);              // 0% takes no time at all
    CHECK(none.total == 300);

    p.holdFraction = 1.0;
    const AhdSpan full = fitAhd(1000, p);
    CHECK(full.hold == 700);            // exactly the remainder after attack and decay
    CHECK(full.total == 1000);

    // A negative/NaN fraction degrades to none rather than to a negative stage.
    p.holdFraction = -1.0;
    CHECK(fitAhd(1000, p).hold == 0);
    p.holdFraction = std::nan("");
    CHECK(fitAhd(1000, p).hold == 0);
}

// --- The Gate/Trigger shape switch --------------------------------------------

// Each mode plays its OWN stage values: the parameter set carries both, so flipping to Trigger
// and back cannot lose either mode's dialled envelope. Asserted on rendered output, not on the
// struct — a voice reading the wrong field would still store the right one.
static void testEachModePlaysItsOwnStageValuesAndTheOtherSurvives() {
    SampleData s = dcSample(4000);
    // Gate: a slow attack. Trigger: an instant onset and a long decay. Deliberately opposite,
    // so a voice reading the wrong shape is unmistakable.
    s.play.adsr.attackFrames = 2000;
    s.play.adsr.sustainLevel = 1.0;
    s.play.trigAhd.attackFrames = 0;
    s.play.trigAhd.decayFrames = 2000;
    s.play.trigAhd.holdFraction = 0.0;
    const AdsrParams originalAdsr = s.play.adsr;

    const auto render = [&]() {
        VoiceEngine eng(1, s);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, 1000);
        return out;
    };

    s.play.playMode = PlayMode::Gate;
    const std::vector<AudioSample> gate = render();
    CHECK(gate[0] < 0.01f);                                   // halfway up a 2000-frame attack
    CHECK(std::fabs(gate[999] - 999.0f / 2000.0f) < 1e-3f);

    // The actual excursion: flip the SAME SampleData to Trigger and render through it — not a
    // fresh copy. A prior revision of this test copied `s` per render and never wrote it, so
    // "back to Gate" compared two renders of an object nothing had touched: it would have
    // passed with trigAhd deleted from the struct.
    s.play.playMode = PlayMode::Trigger;
    const std::vector<AudioSample> trig = render();
    CHECK(trig[0] > 0.99f);                                   // instant onset
    CHECK(std::fabs(trig[999] - (1.0f - 999.0f / 2000.0f)) < 1e-3f);

    // Flip back to Gate on the SAME object: the AHDSR fields must have survived the round trip
    // and the render must reproduce the pre-excursion output exactly.
    s.play.playMode = PlayMode::Gate;
    CHECK(s.play.adsr.attackFrames == originalAdsr.attackFrames);
    CHECK(s.play.adsr.sustainLevel == originalAdsr.sustainLevel);
    const std::vector<AudioSample> again = render();
    for (std::size_t i = 0; i < gate.size(); ++i) CHECK(again[i] == gate[i]);
}

// The same switch on the FILTER envelope, which follows the amp's rule rather than its own:
// the two shapes are stored side by side and each mode reads only its own.
static void testFilterEnvelopeFollowsTheModeShape() {
    SampleData s = dcSample(4000);
    s.play.filter.enabled = true;
    s.play.filter.settings.cutoffNorm = 0.1f;
    s.play.filter.modAmount = 0.9;
    // Gate: the filter envelope opens slowly. Trigger: it opens instantly and closes.
    s.play.filter.env.attackFrames = 2000;
    s.play.filter.env.sustainLevel = 1.0;
    s.play.filter.trigEnv.attackFrames = 0;
    s.play.filter.trigEnv.decayFrames = 2000;
    s.play.filter.trigEnv.holdFraction = 0.0;

    const auto brightnessAt = [&](PlayMode mode, std::size_t frame) {
        SampleData copy = s;
        copy.play.playMode = mode;
        // A DC source through a swept low-pass: the settled level tracks the corner, so the
        // rendered value at a frame is a proxy for how far the envelope has opened it.
        VoiceEngine eng(1, copy);
        eng.noteOn(60, 127);
        std::vector<AudioSample> out;
        eng.render(out, frame + 1);
        return static_cast<double>(out[frame]);
    };

    // Gate opens over time; Trigger starts open and closes. The orderings invert, which cannot
    // happen if both modes read one envelope.
    CHECK(brightnessAt(PlayMode::Gate, 20) < brightnessAt(PlayMode::Gate, 1500));
    CHECK(brightnessAt(PlayMode::Trigger, 20) > brightnessAt(PlayMode::Trigger, 1500));
}

// --- The Trigger tail (item 4) ------------------------------------------------

// The largest sample-to-sample step in the last `window` frames a voice actually produced,
// plus where the voice stopped. A hard cut at a non-zero level shows up here as a step the
// size of that level.
struct TailMeasure {
    double worstStep = 0.0;
    double lastLevel = 0.0;
    std::size_t soundingFrames = 0;
};

static TailMeasure renderTail(VoiceMode voiceMode, PitchEngine engine, std::size_t maxFrames) {
    // A sine, not DC: the shifter's splice machinery needs real waveform to recycle, and a DC
    // source would hide exactly the discontinuity under test.
    SampleData s;
    s.frames.resize(4000);
    for (std::size_t i = 0; i < s.frames.size(); ++i) {
        s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
    }
    s.sampleRate = 48000;
    s.rootNote = 60;
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = engine;
    s.play.trigger.lengthFraction = 1.0;
    // The abrupt-end case the spec keeps representable: zero decay, so nothing in the ENVELOPE
    // hides a discontinuity at the sample end.
    s.play.trigAhd.attackFrames = 0;
    s.play.trigAhd.decayFrames = 0;
    s.play.trigAhd.holdFraction = 1.0;

    VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, voiceMode);
    eng.noteOn(67, 127);  // transposed, so Preserve genuinely runs its shifter

    TailMeasure m;
    std::vector<AudioSample> out;
    for (std::size_t f = 0; f < maxFrames; ++f) {
        eng.render(out, 1);
        if (eng.activeVoiceCount() == 0) break;
        m.soundingFrames = f + 1;
    }
    // Include the frame after the voice freed: the cut itself is the step from the last
    // sounding sample to the silence that follows it.
    const std::size_t end = std::min(m.soundingFrames + 1, out.size());
    for (std::size_t i = 1; i < end; ++i) {
        m.worstStep = std::max(m.worstStep,
                               std::fabs(static_cast<double>(out[i]) -
                                         static_cast<double>(out[i - 1])));
    }
    if (m.soundingFrames > 0) m.lastLevel = std::fabs(static_cast<double>(out[end - 1]));
    return m;
}

// A Trigger one-shot in Preserve must end without a terminal discontinuity, in both voice
// modes. The threshold is stated rather than eyeballed: the source's own steepest
// sample-to-sample slope is 0.8*2*pi/40 ~= 0.126, so the cut must not exceed what the waveform
// itself already does. (Neither of us can judge this by ear — this is the measurable proxy;
// the audible check is Daniel's.)
static void testTriggerPreserveEndsWithoutATerminalDiscontinuity() {
    const double kSourceSlope = 0.8 * 2.0 * kPi / 40.0;
    for (VoiceMode vm : {VoiceMode::Poly, VoiceMode::Mono}) {
        const TailMeasure m = renderTail(vm, PitchEngine::Preserve, 8000);
        // > 0 alone would pass on a hard cut with no blend at all; the ring-out is ~185 frames
        // past the kRingSpanFrames=4000 span, so requiring it past the span is what confirms
        // the blend actually engaged rather than merely stopping somewhere.
        CHECK(m.soundingFrames > 4000);
        CHECK(m.worstStep <= kSourceSlope * 1.5);
        // And the voice genuinely reaches silence rather than being left ringing.
        CHECK(m.lastLevel < 1e-3);
    }
}

// The same cut on the peer path: an AHD whose stages end BEFORE the play span (hold under
// 100% with a zero decay) stops the voice mid-tail, and under Preserve that tail is just as
// synthetic as the one at the sample end.
static void testTriggerPreserveAhdEndingEarlyAlsoRingsOut() {
    SampleData s;
    s.frames.resize(4000);
    for (std::size_t i = 0; i < s.frames.size(); ++i) {
        s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
    }
    s.sampleRate = 48000;
    s.rootNote = 60;
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = PitchEngine::Preserve;
    s.play.trigger.lengthFraction = 1.0;
    s.play.trigAhd.attackFrames = 0;
    s.play.trigAhd.decayFrames = 0;
    s.play.trigAhd.holdFraction = 0.25;  // ends at ~1000 frames, far short of the 4000-frame span

    VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
    eng.noteOn(67, 127);
    std::vector<AudioSample> out;
    std::size_t sounding = 0;
    for (std::size_t f = 0; f < 4000; ++f) {
        eng.render(out, 1);
        if (eng.activeVoiceCount() == 0) break;
        sounding = f + 1;
    }
    CHECK(sounding > 900 && sounding < 1400);  // the AHD ended, not the span
    double worst = 0.0;
    const std::size_t end = std::min(sounding + 1, out.size());
    for (std::size_t i = 1; i < end; ++i) {
        worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
                                          static_cast<double>(out[i - 1])));
    }
    CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
}

// Varispeed is not implicated and must be left exactly as it was: its terminal sample is real
// source content at its natural end, so no ring-out is armed there. Asserted directly against
// the closed-form source waveform (not by comparing two renders): the read head reproduces the
// source exactly through its span, then stops dead with no ring-out appended.
static void testVarispeedTailIsUntouched() {
    const auto render = [](std::size_t frames) {
        SampleData s;
        s.frames.resize(2000);
        for (std::size_t i = 0; i < s.frames.size(); ++i) {
            s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
        }
        s.sampleRate = 48000;
        s.rootNote = 60;
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.trigger.lengthFraction = 1.0;
        s.play.trigAhd.holdFraction = 1.0;
        VoiceEngine eng(1, s);
        eng.noteOn(60, 127);  // unity ratio: the read head walks the source frame for frame
        std::vector<AudioSample> out;
        eng.render(out, frames);
        return out;
    };
    const std::vector<AudioSample> out = render(2400);
    // Unity Varispeed reproduces the source exactly through its span, then stops dead — the
    // pre-change behaviour, with no ring-out appended.
    for (std::size_t i = 0; i < 2000; ++i) {
        CHECK(out[i] == static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0)));
    }
    for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
}

// --- The ring-out must not hold the voice's slot -------------------------------

// A 4000-frame sine one-shot at unity Preserve read rate: the read head leaves the span at
// output frame 4000 and the ~185-frame ring-out runs from there. `kPastEnd` sits inside that
// window, so a note-on at that point is the exact case both tests below need.
static constexpr std::size_t kRingSpanFrames = 4000;
static constexpr std::size_t kPastEnd = 4120;

static SampleData ringOutSample() {
    SampleData s;
    s.frames.resize(kRingSpanFrames);
    for (std::size_t i = 0; i < s.frames.size(); ++i) {
        s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
    }
    s.sampleRate = 48000;
    s.rootNote = 60;
    s.play.playMode = PlayMode::Trigger;
    s.play.pitchEngine = PitchEngine::Preserve;
    s.play.trigger.lengthFraction = 1.0;
    s.play.trigAhd.attackFrames = 0;
    s.play.trigAhd.decayFrames = 0;
    s.play.trigAhd.holdFraction = 1.0;
    return s;
}

static double peakOf(const std::vector<AudioSample>& out, std::size_t from) {
    double peak = 0.0;
    for (std::size_t i = from; i < out.size(); ++i) {
        peak = std::max(peak, std::fabs(static_cast<double>(out[i])));
    }
    return peak;
}

// The Preserve CAP counts sounding notes, not ring-outs. With the cap at one, a second onset
// fired while the first voice is past its end but still ramping must still be admitted —
// otherwise every Preserve one-shot silently swallows the next hit for ~4 ms.
static void testPreserveCapAdmitsANewOnsetDuringTheRingOut() {
    const SampleData s = ringOutSample();
    const auto run = [&](bool fireSecondNote) {
        VoiceEngine eng(4, s, /*preserveCap=*/1, /*window=*/512);
        eng.noteOn(67, 127);
        std::vector<AudioSample> out;
        eng.render(out, kPastEnd);
        if (fireSecondNote) eng.noteOn(72, 127);
        eng.render(out, 400);
        return out;
    };
    // Precondition: the first voice really is still ringing at kPastEnd, so the second onset
    // meets the cap while a past-end voice is alive. Without it the test proves nothing.
    {
        VoiceEngine probe(4, s, /*preserveCap=*/1, /*window=*/512);
        probe.noteOn(67, 127);
        std::vector<AudioSample> discard;
        probe.render(discard, kPastEnd);
        CHECK(probe.activeVoiceCount() == 1);
    }
    // The discriminator is rendered signal, not a voice count: a refused onset leaves the two
    // renders identical from kPastEnd on (the decaying ramp and nothing else).
    const std::vector<AudioSample> withSecond = run(true);
    const std::vector<AudioSample> ringOnly = run(false);
    CHECK(peakOf(ringOnly, kPastEnd) < 0.02);   // the ramp alone is already well down
    CHECK(peakOf(withSecond, kPastEnd) > 0.5);  // the admitted note is at full amplitude
}

// Mono LEGATO takes a voice over by retuning it — which moves the pitch of a dying ramp and
// produces nothing if the voice is already past its own end. A note-on during the ring-out
// must restart instead.
static void testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut() {
    const SampleData s = ringOutSample();
    VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, VoiceMode::Mono,
                    MonoTrigger::Legato);
    eng.noteOn(67, 127);  // held: never released, so the second press is a legato takeover
    std::vector<AudioSample> out;
    eng.render(out, kPastEnd);
    CHECK(eng.activeVoiceCount() == 1);  // precondition: still ringing out
    eng.noteOn(72, 127);
    eng.render(out, 400);
    // A retune of the past-end voice re-enters the run-off branch every frame and emits only
    // the decaying ramp; a restart plays the source from the top.
    CHECK(peakOf(out, kPastEnd) > 0.5);
}

// The read-head exhaustion path is NOT scoped to Trigger: a held Gate note whose source runs
// out with no sustain loop is cut at its sustain level, and under Preserve that cut lands on
// the same recycled synthetic tail the Trigger one-shot's does.
static void testGatePreserveRunOffRingsOutToo() {
    SampleData s = ringOutSample();
    s.play.playMode = PlayMode::Gate;
    s.play.adsr.attackFrames = 0;
    s.play.adsr.decayFrames = 0;
    s.play.adsr.sustainLevel = 1.0;  // held at full level when the source runs out
    s.loop.hasLoop = false;

    VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
    eng.noteOn(67, 127);  // held; never released
    std::vector<AudioSample> out;
    std::size_t sounding = 0;
    for (std::size_t f = 0; f < 8000; ++f) {
        eng.render(out, 1);
        if (eng.activeVoiceCount() == 0) break;
        sounding = f + 1;
    }
    CHECK(sounding > kRingSpanFrames);  // it rings past the source rather than stopping dead
    // Same measurable bar as the Trigger tail: no step larger than the source waveform's own
    // steepest slope. (The audible judgement is Daniel's; this is the proxy.)
    double worst = 0.0;
    const std::size_t end = std::min(sounding + 1, out.size());
    for (std::size_t i = 1; i < end; ++i) {
        worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
                                          static_cast<double>(out[i - 1])));
    }
    CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
}

// --- The pitch envelope's span domain ------------------------------------------

// The pitch envelope counts OUTPUT frames while the playable span is a SOURCE-frame count, so
// a transposed Varispeed voice must have its span converted. Measured through the read rate the
// envelope itself drives: at +12 semitones (ratio 2) with a full-span hold of -12 semitones the
// voice reads at unity while the envelope holds and at double speed after it, so the note's
// OUTPUT length is a direct readout of the envelope's span.
//   +12 st, span/2 = 2000 output frames of hold: 2000 + 2000/2 = 3000 output frames
//   +24 st, span/4 = 1000 output frames of hold: 1000 + 3000/4 = 1750 output frames
// The un-converted source-frame span holds for 4000 output frames in BOTH cases, so the note
// runs exactly 4000 at either transposition — the length stops tracking the ratio at all.
static void testPitchEnvelopeSpanIsOutputFramesUnderVarispeed() {
    const auto soundingFrames = [](int note, double peakSemis) {
        SampleData s = dcSample(4000);
        s.play.playMode = PlayMode::Trigger;
        s.play.pitchEngine = PitchEngine::Varispeed;
        s.play.trigger.lengthFraction = 1.0;
        s.play.trigAhd.attackFrames = 0;
        s.play.trigAhd.decayFrames = 0;
        s.play.trigAhd.holdFraction = 1.0;
        s.play.pitchEnv.enabled = true;
        s.play.pitchEnv.peakSemitones = peakSemis;  // cancels the transposition while it holds
        s.play.pitchEnv.shape.attackFrames = 0;
        s.play.pitchEnv.shape.decayFrames = 0;
        s.play.pitchEnv.shape.holdFraction = 1.0;

        VoiceEngine eng(1, s);
        eng.noteOn(note, 127);
        std::vector<AudioSample> out;
        std::size_t sounding = 0;
        for (std::size_t f = 0; f < 8000; ++f) {
            eng.render(out, 1);
            if (eng.activeVoiceCount() == 0) break;
            sounding = f + 1;
        }
        return sounding;
    };
    CHECK(soundingFrames(72, -12.0) == 3000);
    CHECK(soundingFrames(84, -24.0) == 1750);
}

// --- Live delivery must not resurrect a finished AHD ---------------------------

// A live move must never UN-finish a completed AHD. Reachable on a sounding voice past its
// fitted total but still active() (the ring-out past a Trigger AHD's own early end, voice.h) —
// VoiceEngine::applyLiveToActive runs on any active() voice, so a live move there that reopens
// the total could otherwise make a finished one-shot briefly resurge.
static void testApplyLiveNeverUnfinishesACompletedAhd() {
    AhdParams p;
    p.attackFrames = 10;
    p.decayFrames = 10;
    p.holdFraction = 0.0;  // total = 20, well short of the 1000-frame span
    AhdEnvelope ahd;
    ahd.configure(1000, p);
    CHECK(ahd.amplitudeAt(500.0) == 0.0);  // past the fitted total: latched finished
    CHECK(ahd.finished());

    // A live move that would, on a FRESH fit, reopen the total out across the whole span.
    AhdParams reopened = p;
    reopened.holdFraction = 1.0;
    ahd.applyLive(500.0, reopened);  // same sourceOffset the voice is sitting at
    CHECK(ahd.finished());           // must stay finished — no resurge
    CHECK(ahd.amplitudeAt(500.0) == 0.0);
}

// --- Migration shape ----------------------------------------------------------

// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
// express — nothing the retired mechanism could say is lost.
static void testZeroFadeOutMigratesToAnAbruptEnd() {
    AhdParams migrated;
    migrated.attackFrames = 0;
    migrated.decayFrames = 0;
    migrated.holdFraction = 1.0;
    AhdEnvelope ahd;
    ahd.configure(500, migrated);
    CHECK(ahd.amplitudeAt(0.0) == 1.0);
    CHECK(ahd.amplitudeAt(499.0) == 1.0);   // still at unity on the last frame
    CHECK(ahd.amplitudeAt(500.0) == 0.0);   // and off on the next
    CHECK(ahd.finished());
}

int main() {
    testNeutralExponentReproducesTheLinearEvaluationExactly();
    testExponentSweepStaysFiniteMonotoneAndInRange();
    testExponentActuallyReshapesTheStage();

    testAhdSumNeverExceedsTheSpanForAnyTriple();
    testHoldFractionEndpoints();

    testEachModePlaysItsOwnStageValuesAndTheOtherSurvives();
    testFilterEnvelopeFollowsTheModeShape();

    testTriggerPreserveEndsWithoutATerminalDiscontinuity();
    testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
    testVarispeedTailIsUntouched();
    testPreserveCapAdmitsANewOnsetDuringTheRingOut();
    testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut();
    testGatePreserveRunOffRingsOutToo();

    testPitchEnvelopeSpanIsOutputFramesUnderVarispeed();

    testApplyLiveNeverUnfinishesACompletedAhd();

    testZeroFadeOutMigratesToAnAbruptEnd();

    if (g_fail == 0) {
        std::printf("all staged_envelopes tests passed\n");
        return 0;
    }
    std::printf("%d staged_envelopes check(s) failed\n", g_fail);
    return 1;
}
