#pragma once
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
//
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
// configured an out-of-line render would put a call — and the envelope ticks behind it —
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.

#include <cmath>
#include <cstdint>
#include <vector>

#include "core/audio/peaks.h"
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/time_stretch.h"
#include "core/instrument/engine/velocity_curve.h"

namespace reasampler {

using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::SplineCursor;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
using instrument::engine::loop::crossfadeWeight;
using instrument::engine::loop::crossfadedSource;
using instrument::engine::loop::lerpSource;

// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed.
inline double pitchRatio(int note, int rootNote) {
    return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
}

// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
// At the root note the offset is 0 regardless of keyTrack.
// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start().
inline constexpr double kLatchFromSnapshot = -1.0;

inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
    const double semis = static_cast<double>(note - rootNote) * keyTrack;
    return std::pow(2.0, semis / 12.0);
}

// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset
// transposes nothing and costs nothing.
inline double semitoneRatio(double semitones) {
    return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0);
}

// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve
// flat at 0 — the default — yields EXACTLY 1.0 at every velocity.
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
    return semitoneRatio(curve.eval(static_cast<double>(velocity)) *
                         kVelocityPitchRangeSemitones);
}

// One octave expressed in the cutoff control's normalized domain, read out of the filter
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
inline double filterNormPerOctave() {
    namespace flt = instrument::engine::filter;
    return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
                               flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
}

// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero
// compensation and kept the full click — the difference-seed has no such hole. Off by
// default so the bare core stays byte-identical to the pre-fix engine; the processor
// shell opts in.
inline constexpr double kDeclickDecay = 0.95;  // per-frame decay of the compensation
inline constexpr double kDeclickFloor = 1e-4;  // below this the ramp is done (~ -80 dB)

// How many frames the ramp emits before the weight drops under the floor. Counted the way
// advanceFrame runs it — emit, decay, test — rather than solved in closed form, so the two
// can never disagree. RATE-INDEPENDENT: the decay is per frame, not per second, so an offline
// pass at any rate pads by the same count.
inline constexpr std::int64_t declickRampFrames() {
    std::int64_t n = 0;
    for (double w = 1.0; w >= kDeclickFloor; w *= kDeclickDecay) ++n;
    return n;
}
inline constexpr std::int64_t kDeclickFrames = declickRampFrames();

// A single voice: one active note playing the loaded capture, repitched and enveloped.
// Reads the sample by fractional frame position with linear interpolation, advancing by the
// pitch ratio; loops the sustain region for held notes past the loop end.
class Voice {
public:
    // Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
    // instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
    // AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
    // seconds at load). Preserve shifters must already be pre-sized
    // (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
    // no allocation) since it runs on the audio thread inside process(). Byte-identical to
    // the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
    // amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
    // and this voice is currently active (a takeover/steal restart, not a fresh start), arms
    // the difference-seeded declick compensation on the first frame after the restart (see
    // kDeclickDecay above). A fresh start never declicks.
    //
    // `stretchRate` is the playback rate — source frames consumed per output frame, clamped to
    // [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not
    // a member set separately) because the loop fold and the contour scale it composes with are
    // both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves;
    // under Varispeed it folds into the read increment beside key-tracking, so pitch moves with
    // it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block
    // to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value —
    // sample.play.playRate is NOT read here, because the published block outranks the snapshot's
    // possibly-stale copy of it.
    //
    // `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched
    // class and arrive the same way, for the same structural reason. Negative = not supplied,
    // which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) —
    // both are non-negative by domain, so the sentinel can never collide with a real one.
    // VoiceEngine::startVoice always supplies them, resolved from the published block when there
    // is one; the sentinel is for a caller that has no block to consult.
    void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false,
               double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot,
               double lengthFraction = kLatchFromSnapshot);

    // Mono legato takeover: re-pitch this active voice to `note` without touching the
    // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
    // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
    void retune(int note);

    // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
    // ignores note-off and plays through to its play length).
    void release();

    // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
    // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
    // RT-safe: no allocation, no lock.
    void hardStop() { active_ = false; }

    // True while producing (or about to produce) sound, including any declick ring-out
    // tail past the note's playable span.
    bool active() const { return active_; }
    // True while sounding a playable note — active and the amplitude envelope hasn't
    // finished. A voice ringing out a declick tail past note end is active() but not
    // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
    // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
    bool soundingNote() const { return active_ && !amplitudeDone_; }
    int note() const { return note_; }
    // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
    std::uint64_t startOrder() const { return startOrder_; }
    void setStartOrder(std::uint64_t order) { startOrder_ = order; }
    bool releasing() const { return releasing_; }
    // The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
    // meaningful while active().
    PitchEngine pitchEngine() const { return pitchEngine_; }

    // Applies the live-parameter block to a voice that is already sounding (or, with `snap`,
    // to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so
    // the per-sample shape is unchanged; every continuous control glides toward its new value
    // from here rather than jumping to it. `snap` takes the values outright — glides AND
    // envelopes: a fresh note has nothing to glide from, and its copy may predate the edit.
    //
    // What is NOT here is the point: velocity and its curve result, the note number and the
    // pitch ratio, and the decoded PCM stay latched at note-on.
    void applyLive(const instrument::engine::LiveValues& live, bool snap);

    // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
    // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
    // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
    // Idempotent: a re-presize to the same window is a cheap no-op.
    void presizePreserveShifters(std::int64_t windowFrames);

    // Renders one frame's contribution, advancing the read head and envelope by one output
    // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
    // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
    // Mono path (channel 0 only).
    AudioSample renderFrame() {
        AudioSample discard = 0.0f;
        return advanceFrame(/*stereo=*/false, discard);
    }

    // Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
    // envelope by exactly one frame (the envelope ticks once per frame, shared across both
    // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
    // on the same conditions as the mono path, writing 0 to both.
    void renderFrameStereo(AudioSample& l, AudioSample& r) {
        r = 0.0f;
        l = advanceFrame(/*stereo=*/true, r);
    }

private:
    // THE fold of every pitch factor that is constant for the note into one number, so
    // advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
    // always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
    // and the Rate ratio — which start() zeroes out of this product when the note is running the
    // Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
    // shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
    void recomputeBaseRatio() {
        if (sample_ == nullptr) return;
        baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
                     velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
    }

    // The rate the read head consumes SOURCE at, counting only the factors whose stage-time
    // coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
    // transposes inside the shifter and never touches the read. Under Varispeed both Rate and
    // Pitch are factors of the read increment and both are compensated: they are two views of one
    // multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
    // velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
    // sounds, and compensating them would move every note off the root.
    double stageFitRate() const {
        return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
    }

    // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
    // sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source
    // span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate —
    // multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %,
    // Pitch 0 st) returns the argument untouched, which is what keeps the unity render
    // bit-identical.
    AhdParams rateFittedAhd(const AhdParams& a) const {
        const double fit = stageFitRate();
        if (fit == 1.0) return a;
        AhdParams out = a;
        out.attackFrames =
            static_cast<std::int64_t>(static_cast<double>(a.attackFrames) * fit + 0.5);
        out.decayFrames =
            static_cast<std::int64_t>(static_cast<double>(a.decayFrames) * fit + 0.5);
        return out;
    }

    // The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken
    // against the playable SOURCE span, so the span converts by the rate the read head consumes
    // source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x
    // envFactor: a deep pitch envelope makes it a first-order approximation, not exact.
    //
    // Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope
    // rather than leaving it on the offset the note started at. Only that live factor is
    // re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's
    // documented drift (retune) exactly where it was.
    std::int64_t pitchEnvSpanFrames() const {
        if (sample_ == nullptr) return 0;
        const double postStart = static_cast<double>(
            static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
        const double readRate =
            preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
        const double span = (readRate > 0.0) ? postStart / readRate : postStart;
        return static_cast<std::int64_t>(span + 0.5);
    }

    // The read head as a fraction of the whole sample — the domain every spline EG is a pure
    // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
    // its opening value.
    double splinePhase() const { return readPos_ * splineScale_; }

    // This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
    // at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
    // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
    // is evaluated at the source offset (readPos - startFrame), which is why its stage frames are
    // fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so
    // advanceFrame frees the voice.
    double tickAmplitude() {
        double amp;
        // playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
        // splineActive, play_params.h); the guard is a pure-core defense against a hand-built
        // SampleData pairing Gate with an amp spline, which would otherwise bypass env_
        // entirely — release() then has no envelope to end, and an active sustain loop rings
        // forever.
        if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
            // Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
            // finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
            amp = ampSplineCur_.eval(splinePhase());
            if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
                ampSplineCur_.segmentEndValue() == 0.0) {
                amplitudeDone_ = true;
            }
        } else if (playMode_ == PlayMode::Gate) {
            amp = env_.tick();
            if (env_.finished()) amplitudeDone_ = true;
        } else {
            amp = ampAhd_.amplitudeAt(sourceOffset());
            if (ampAhd_.finished()) amplitudeDone_ = true;
        }
        return amp;
    }

    // Frames into the Trigger play span at the current read head — the domain both
    // sustain-less envelopes are evaluated over.
    double sourceOffset() const { return readPos_ - static_cast<double>(startFrame_); }

    // Advances the filter envelope and re-solves the corner from the modulated cutoff. The
    // solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
    // rather than staircasing. State preservation across the solve is voice_filter's own
    // contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
    // control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
    //
    // Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
    // note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
    // held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
    // compare the value itself, so they can never suppress a move the ear would hear.
    // filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
    // through both so a moved base always re-solves.
    void tickFilterCutoff() {
        if (filterModAmount_ == 0.0 && filterSolved_) return;
        // The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
        // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
        // branch is perfectly predicted.
        const double envOut = filterSplineCur_.active()
                                  ? filterSplineCur_.eval(splinePhase())
                                  : ((playMode_ == PlayMode::Gate)
                                         ? filterEnv_.tick()
                                         : filterAhd_.amplitudeAt(sourceOffset()));
        double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
        if (cut < 0.0) cut = 0.0;
        if (cut > 1.0) cut = 1.0;
        const float cutNorm = static_cast<float>(cut);
        if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
        filterSolvedCutoff_ = cutNorm;
        filterSolved_ = true;
        filter_.setCutoffNorm(cutNorm, filterRate_);
    }

    // The cutoff position before the envelope: the stored knob position plus this note's
    // velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move
    // the note), and when a live move changes the knob position or the key-track depth —
    // never per frame.
    double filterCutoffBaseTarget(int note) const {
        double base = filterCutoffNorm_ + filterVelOffset_;
        if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
            base += filterKeyTrack_ *
                    (static_cast<double>(note - sample_->rootNote) / 12.0) *
                    filterNormPerOctave();
        }
        if (base < 0.0) base = 0.0;
        if (base > 1.0) base = 1.0;
        return base;
    }

    // Takes the base outright (no glide) — a note-on or a retune is a new note position, not a
    // knob move, so there is nothing to glide from.
    void updateFilterCutoffBase(int note) {
        const double base = filterCutoffBaseTarget(note);
        rBaseCutoff_.set(base);
        filterBaseCutoff_ = static_cast<float>(base);
        filterSolved_ = false;  // forces the next frame to solve
    }

    // The full solve, from the tone-control ramps' current values, at the current base cutoff —
    // the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind
    // so an unmoved live block reproduces start()'s state exactly. State is preserved across
    // prepare() by contract (voice_filter.h), which is what makes a live tone move glide
    // rather than click.
    void prepareFilterFromRamps() {
        filterSettings_.resonanceNorm = static_cast<float>(rResonance_.value);
        filterSettings_.morphNorm = static_cast<float>(rMorph_.value);
        filterSettings_.driveNorm = static_cast<float>(rDrive_.value);
        filterSettings_.cutoffNorm = filterBaseCutoff_;
        filter_.prepare(filterSettings_, filterRate_);
        filterSolvedCutoff_ = filterBaseCutoff_;
        filterSolved_ = true;
    }

    // Advances the five live filter-control glides by one frame. Q, morph and drive are
    // prepare()-cadence constants, so a move on any of them costs the full solve while the
    // glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed
    // tickFilterCutoff's own cheap cutoff-only solve instead.
    void tickFilterRamps() {
        bool tone = false;
        if (rResonance_.tick()) tone = true;
        if (rMorph_.tick()) tone = true;
        if (rDrive_.tick()) tone = true;
        if (rBaseCutoff_.tick()) {
            filterBaseCutoff_ = static_cast<float>(rBaseCutoff_.value);
            filterSolved_ = false;
        }
        if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value;
        if (tone) prepareFilterFromRamps();
        filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() ||
                         rBaseCutoff_.moving() || rModAmount_.moving();
    }

    // Seeds the takeover compensation on the first frame after a restart: the ramp is the
    // actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
    // applied ungated so the boundary frame reproduces the old level exactly.
    void seedDeclick() {
        // The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
        // exact boundary identity whatever the new envelope's first value. Each subsequent
        // frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
        // max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
        // rises while the weight is still significant. (An earlier revision stored the frozen
        // difference (ref − x₀), which could exceed full scale if outₙ rose while that
        // residue was still large.)
        declickPending_ = false;
        declickWeight_ = 1.0;  // one weight for both channels
        // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
        // if ref ≈ 0 there is nothing to blend.
        declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
                          declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
    }

    // Rings the voice's last rendered output out instead of hard-cutting it when the read head
    // reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real
    // source content at its natural end and its stop is left byte-identical; Preserve's is
    // recycled synthetic tail (freezeTail stops the writer a full window before the read head
    // arrives), whose level bears no relation to the source's own ending — cutting it at
    // whatever amplitude the splice machinery happens to be at is the end-of-sample click.
    // Reuses the takeover blend so the boundary frame reproduces the last level exactly.
    void seedTerminalDeclick() {
        if (pitchEngine_ != PitchEngine::Preserve) return;
        declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
        declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
        declickWeight_ = 1.0;
        declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
                          declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
    }

    // Shared read/advance for both render paths: computes the interpolated per-channel
    // value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
    // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
    // whether the second channel is read (into `outR`). Returns the channel-0 value.
    //
    // INLINE BY CONSTRAINT — see the file header.
    AudioSample advanceFrame(bool stereo, AudioSample& outR) {
        if (!active_ || sample_ == nullptr) {
            if (stereo) outR = 0.0f;
            return 0.0f;
        }

        const std::vector<AudioSample>& pcm = sample_->frames;
        const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
        // Read the second channel only for a genuinely stereo sample; a mono sample plays
        // dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
        const bool haveR = stereo && sample_->channelCount() == 2;
        const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;

        // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The
        // span was folded once at note-on (loop_span.h); an invalid or absent loop leaves
        // loop_.active false and this whole path off. Under Preserve the loop is over the
        // source read (loop the source, shift the output).
        const ResolvedLoop& loop = loop_;
        if (loop.active) {
            const double loopLen = static_cast<double>(loop.length);
            while (readPos_ >= static_cast<double>(loop.end)) {
                readPos_ -= loopLen;  // wrap by exactly one loop length, preserving phase.
            }
        }

        // Trigger frees once the read head reaches playEnd; the envelope also finishes at the
        // same count, either latches idle.
        const bool triggerRanOff =
            playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
        // Ran off the sample end with no usable loop -> voice is done, except an in-flight
        // takeover declick rings out here instead of hard-cutting — dropping it would
        // re-introduce a step on exactly the path the ramp exists for (a restart whose new
        // play span ends within the ramp). With no declick (the common case) this is
        // byte-identical to the plain idle-out.
        if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
            // The NOTE is over the moment the read head leaves its span, whether or not a ramp
            // still rings: no later frame can carry envelope output. Latching here is what keeps
            // a ringing-out voice out of soundingNote() — the Preserve cap would otherwise
            // refuse a new onset, and mono legato would retune a voice already past its end
            // (silencing the new note) for the whole ~4 ms ramp.
            amplitudeDone_ = true;
            if (declickPending_) seedDeclick();
            if (!declickActive_) seedTerminalDeclick();
            if (declickActive_) {
                // Bounded blend at silence: outCurrent == 0, so the blend is
                // w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
                // floor-checked on the weight itself.
                const double l = declickWeight_ * declickRefL_;
                const double r = declickWeight_ * declickRefR_;  // same weight both channels
                declickWeight_ *= kDeclickDecay;
                if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
                    declickActive_ = false;
                    active_ = false;
                }
                lastOutL_ = l;
                lastOutR_ = stereo ? r : l;
                if (stereo) outR = static_cast<AudioSample>(r);
                return static_cast<AudioSample>(l);
            }
            active_ = false;
            if (stereo) outR = 0.0f;
            return 0.0f;
        }

        // Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
        const double amp = tickAmplitude();
        // Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
        // the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
        // same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
        // still holds the PREVIOUS frame — this one is already silent. Gate is left out of THIS
        // site only: its amplitude reaches zero through a release, so nothing here is cut
        // mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate
        // note whose source runs out with no loop is cut at its sustain level, and under
        // Preserve that cut lands on the same recycled synthetic tail.
        if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
            playMode_ == PlayMode::Trigger) {
            seedTerminalDeclick();
        }
        const double gain = amp * velocityGain_;
        const double pitchEnvSemis = pitchSplineCur_.active()
                                         ? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
                                         : pitchEnv_.tick();

        // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
        // pow entirely — no per-frame transcendental on the common path.
        const double envFactor =
            (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);

        // Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
        // it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
        // and the amp envelope shapes the filtered result (drive included).
        double outL, outRlocal = 0.0;
        if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
            // The two rates the shifter takes (pitch_shift.h owns why they are independent):
            // the source is FED at stretchRate_, and the tap is SHIFTED by
            // 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
            // never to the read rate. The feed runs one window ahead of readPos_ (the rings were
            // primed with that window at start()), under the same sustain-loop wrap rule,
            // reading integer source frames into the ring — no RATE-DEPENDENT interpolation
            // (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a
            // splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read,
            // splice or no; that constant fractional delay is not a rate coupling.
            const bool stereoOut = stereo && haveR && shiftR_.configured();
            // The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate.
            // Once the feed reaches that bound the source is exhausted — feeding the held last
            // sample instead would give the splice correlation a DC plateau it can't align on
            // (periodic troughs at the splice cadence, growing toward the note end). Freezing the
            // shifter's writer means no padding ever enters the ring, so the splice machinery
            // keeps recycling the frozen all-real tail — a continuous tone through the voice's
            // own end. The sustain-loop path never gets here: the wrap keeps the cursor inside
            // the loop forever.
            const std::int64_t feedBound =
                (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
                    ? playEnd_ : frameCount;
            const double shift = baseRatio_ * envFactor;
            shiftL_.setShiftRatio(shift);
            if (stereoOut) shiftR_.setShiftRatio(shift);

            // 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
            // written without producing output; the last rides the ordinary 1-in-1-out
            // process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
            // Crossfaded on the way IN to the shifter, not on the way out: loop the source,
            // shift the output.
            const std::int64_t due = stretch_.due(stretchRate_);
            AudioSample feedL = 0.0f, feedR = 0.0f;
            bool fed = false;
            for (std::int64_t k = 0; k < due; ++k) {
                if (fed) {  // an earlier frame of this batch: write-only, no output
                    shiftL_.writeFrame(feedL);
                    if (stereoOut) shiftR_.writeFrame(feedR);
                }
                const std::int64_t q = stretch_.next(loop);
                if (q >= feedBound) {
                    shiftL_.freezeTail();  // idempotent; input ignored while frozen
                    if (stereoOut) shiftR_.freezeTail();
                    feedL = feedR = 0.0f;
                } else {
                    const double xw = crossfadeWeight(loop, static_cast<double>(q));
                    feedL = crossfadedSource(pcm, loop, q, xw);
                    if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw);
                }
                fed = true;
            }
            const double shiftedL =
                fed ? static_cast<double>(shiftL_.process(feedL))
                    : static_cast<double>(shiftL_.processNoInput());
            outL = shiftedL;
            if (stereo) {
                if (stereoOut) {
                    // Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
                    // splice decisions via processLinked — one correlation search, one lag, one
                    // splice schedule for both channels (standard stereo SOLA). An independent
                    // per-channel search re-drew an inter-channel offset of up to +/-maxLag at
                    // every splice: stereo image wander at the splice cadence + mono-sum
                    // combing. Each shifter is still processed EXACTLY ONCE per output frame
                    // (never twice — that would advance its heads twice and corrupt the state);
                    // the batch's earlier frames go through writeFrame, which produces none.
                    // Gated on haveR so a MONO sample never touches shiftR_ — start() only
                    // primes it for genuinely stereo samples, and a stale un-primed ring must
                    // not leak a previous note.
                    outRlocal =
                        fed ? static_cast<double>(
                                  shiftR_.processLinked(feedR, shiftL_.lastSplice()))
                            : static_cast<double>(
                                  shiftR_.processNoInputLinked(shiftL_.lastSplice()));
                } else {
                    // Mono sample in stereo mode (dual-mono): shiftL_ already produced the
                    // shifted value from the mono feed; mirror it to R. Do NOT call
                    // shiftL_.process again this frame.
                    outRlocal = shiftedL;
                }
            }
            // Preserve advances the read head at the STRETCH rate — the one duration control.
            // Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
            // therefore stays a source-frame fact and scales by construction.
            //
            // The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
            // this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
            ratio_ = stretchRate_;
        } else {
            // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
            // pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
            // the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
            //
            // Linear interpolation between the two bracketing SOURCE frames at the read head.
            // For the loop case, the second point wraps to loopStart so the seam is continuous.
            const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
            const double frac = readPos_ - static_cast<double>(i0);
            std::int64_t i1 = i0 + 1;
            if (loop.active && i1 >= loop.end) {
                i1 = loop.start;  // seamless wrap for the interpolation partner.
            }
            const bool i0ok = (i0 >= 0 && i0 < frameCount);
            const bool i1ok = (i1 >= 0 && i1 < frameCount);
            const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
                                ((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
                                 (i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
            outL = srcL;
            if (stereo) {
                const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
                                    ((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
                                     (i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
                outRlocal = srcR;
            }
            // Loop crossfade: blend toward the same read head one loop length earlier, which
            // is the material the wrap is about to hand over to. Zero outside the fade region
            // (and always, with no fade dialled), so the un-crossfaded read stays exactly the
            // shape it was.
            const double xw = crossfadeWeight(loop, readPos_);
            if (xw > 0.0) {
                const double tap = readPos_ - static_cast<double>(loop.length);
                outL += xw * (lerpSource(pcm, frameCount, tap) - outL);
                if (stereo) outRlocal += xw * (lerpSource(pcmR, frameCount, tap) - outRlocal);
            }
            ratio_ = baseRatio_ * envFactor;
        }

        // Skipped whole when disengaged (the default), so an un-filtered render stays
        // bit-identical to the pre-filter engine.
        if (filterOn_) {
            if (filterRamping_) tickFilterRamps();  // false at rest: one predicted branch
            tickFilterCutoff();
            outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
            // Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
            // filtered result is exactly what a second identical filter would produce — one
            // less kernel pass per frame for the same samples.
            if (stereo) {
                outRlocal = haveR
                    ? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
                    : outL;
            }
        }

        outL *= gain;
        if (stereo) outRlocal *= gain;

        // Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
        // restart, seed the blend weight at 1.0 so this frame's output is
        //   outₙ*(1−w) + ref*w  =  out*(1−1) + ref*1  =  ref  (exact boundary identity).
        // Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
        // kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
        // frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
        // [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
        // while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
        // extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
        // one extra subtract.
        if (declickPending_) seedDeclick();
        if (declickActive_) {
            const double addL = declickWeight_ * (declickRefL_ - outL);
            const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
            outL += addL;
            if (stereo) outRlocal += addR;
            declickWeight_ *= kDeclickDecay;  // one shared weight — both channels decay together
            if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
                declickActive_ = false;
            }
        }

        if (stereo) outR = static_cast<AudioSample>(outRlocal);

        // Track the value this voice actually contributed THIS frame (post-gain, incl. any
        // running declick) — a future takeover restart seeds its declick from exactly this. In
        // a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
        // of a mono sample), so a later stereo takeover still has a sane R seed.
        lastOutL_ = outL;
        lastOutR_ = stereo ? outRlocal : outL;

        readPos_ += ratio_;

        // A finished amplitude envelope frees the voice — unless a takeover declick still
        // rings: the envelope contributes 0 from here on, so the remaining frames are the bare
        // ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
        if (amplitudeDone_ && !declickActive_) {
            active_ = false;
        }
        return static_cast<AudioSample>(outL);
    }

    bool active_ = false;
    bool releasing_ = false;
    int note_ = 0;
    double velocityGain_ = 1.0;
    double baseRatio_ = 1.0;     // recomputeBaseRatio's product: every constant pitch factor
    double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
    double pitchOffsetRatio_ = 1.0;  // the Pitch knob's factor — LIVE, re-applied by applyLive
    double rateRatio_ = 1.0;     // Rate's factor of the read increment; start() owns when it is 1
    // Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the
    // snapshot so a legato retune and a live block re-apply the note's own value; a published
    // move reaches the next note only.
    double keyTrack_ = kKeyTrackDefault;
    // Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters
    // were never sized falls back to the varispeed one, and the two domains differ. Latched at
    // note-on beside rateRatio_, which start() resolves from the same predicate.
    bool preserveRead_ = false;
    // baseRatio_ with the live Pitch factor divided back out, latched at note-on: what
    // pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is
    // exactly 1.0), which is what keeps the unity span bit-identical.
    double pitchSpanBaseRate_ = 1.0;
    double ratio_ = 1.0;         // fractional source frames advanced per output frame (this frame)
    double readPos_ = 0.0;       // fractional frame index into the sample
    const SampleData* sample_ = nullptr;

    // Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by
    // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
    // readPos_ >= playEnd_).
    PlayMode playMode_ = PlayMode::Gate;
    AdsrEnvelope env_;
    AhdEnvelope ampAhd_;
    std::int64_t startFrame_ = 0;  // clamped initial read frame; the span-offset origin
    std::int64_t playEnd_ = 0;   // Trigger: source-frame end; Gate: unused
    bool amplitudeDone_ = false; // set when the active amplitude envelope finished

    // The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
    // SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
    // inactive, so a purely staged instrument's per-sample path gains three predicted branches
    // and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
    // shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
    SplineCursor ampSplineCur_;
    SplineCursor pitchSplineCur_;
    SplineCursor filterSplineCur_;
    double splineScale_ = 0.0;
    double pitchSplineDepth_ = 0.0;

    // The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
    // are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
    // Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
    ResolvedLoop loop_;

    // The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
    // different envelope phases are filtered independently. filterCutoffNorm_ keeps the
    // unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
    // solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
    // filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
    instrument::engine::filter::VoiceFilter filter_;
    AdsrEnvelope filterEnv_;   // Gate
    AhdEnvelope filterAhd_;    // Trigger
    bool filterOn_ = false;
    double filterRate_ = 0.0;
    double filterCutoffNorm_ = 1.0;
    double filterModAmount_ = 0.0;
    // The curve's value at THIS note's velocity — a fact about the note, latched at note-on —
    // and the product with the live depth, which a live depth move recomputes.
    double filterVelCurve_ = 0.0;
    double filterVelOffset_ = 0.0;
    double filterKeyTrack_ = 0.0;
    instrument::engine::filter::FilterSettings filterSettings_{};  // the note's tone controls
    float filterBaseCutoff_ = 1.0f;    // cutoff before the envelope, clamped
    float filterSolvedCutoff_ = 1.0f;  // the position the live coefficients were solved from
    bool filterSolved_ = false;        // false forces the next frame to solve

    // Live-parameter glides (live_params.h). Every one is parked at its target unless a move
    // is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live
    // engine's exact shape. All five live in the filter's control domains — the envelopes
    // need no ramp here, because holding normalized stage position is continuous by
    // construction and their two genuine level steps are absorbed inside AdsrEnvelope /
    // PitchEnvelope themselves.
    bool filterRamping_ = false;
    instrument::engine::ValueRamp rBaseCutoff_;
    instrument::engine::ValueRamp rModAmount_;
    instrument::engine::ValueRamp rResonance_;
    instrument::engine::ValueRamp rMorph_;
    instrument::engine::ValueRamp rDrive_;

    // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
    // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
    //
    // The shifter rings are primed at start() with the first window of the actual upcoming
    // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
    // silence, and splices always land in real history. stretch_ is the integer source frame
    // fed to the shifters next plus the fractional rate debt; it runs one window ahead of
    // readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one
    // window apart at every stretch. Once it passes the last real frame (Gate: sample end;
    // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
    // splice machinery recycles the frozen real tail through the note end (see advanceFrame).
    // primeBuf_ is the presized scratch the prime stream is assembled into.
    PitchEngine pitchEngine_ = PitchEngine::Varispeed;
    PitchEnvelope pitchEnv_;
    PitchShifter shiftL_;
    PitchShifter shiftR_;
    instrument::engine::StretchCursor stretch_;
    double stretchRate_ = 1.0;  // Preserve playback rate, clamped and latched at note-on
    std::vector<AudioSample> primeBuf_;

    // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
    // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
    // restart calls seedDeclick to arm the bounded blend:
    //   outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
    //   L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
    // lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
    // between) must record the same pre-cut reference, not a phantom 0. The whole declick
    // state is cleared on a fresh (non-takeover) start.
    bool declickPending_ = false;
    bool declickActive_ = false;
    double declickRefL_ = 0.0;    // clamped pre-cut reference (bounded blend target)
    double declickRefR_ = 0.0;
    double declickWeight_ = 0.0;  // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
    double lastOutL_ = 0.0;
    double lastOutR_ = 0.0;

    std::uint64_t startOrder_ = 0;
};

}  // namespace reasampler
