#pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
// with loop-point-aware sustain.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
//
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
// core does no file I/O — it is handed decoded sample frames and produces audio frames.

#include <cstddef>
#include <cstdint>
#include <vector>

#include "peaks.h"        // AudioSample (float)
#include "pitch_shift.h"  // PitchShifter (S16 Preserve engine DSP core)

namespace reasampler {

// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
// itself never branches on it — the mode only picks which render overload the shell drives.
enum class ChannelMode { Mono, Stereo };

// ---------------------------------------------------------------------------
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
// with the rest of the engine machinery; only the value structs need to precede SampleData.
// ---------------------------------------------------------------------------

// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
struct AdsrParams {
    std::int64_t attackFrames  = 0;
    std::int64_t holdFrames    = 0;     // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
    std::int64_t decayFrames   = 0;
    double       sustainLevel  = 1.0;   // 0..1
    std::int64_t releaseFrames = 0;
};

// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
enum class PlayMode { Gate, Trigger };

// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
struct TriggerParams {
    double       lengthFraction = 1.0;  // (0,1] of the post-start span to play
    std::int64_t fadeInFrames   = 0;    // 0->1 ramp at the head
    std::int64_t fadeOutFrames  = 0;    // 1->0 ramp anchored to playEnd
};

// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
// so a third curve can join without a signature change.
enum class FadeCurve { EqualPower, Linear };

// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;

// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve };

// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
// engine" holds for the core's own regression tests (an octave up still halves duration in the
// bare engine); the Preserve product default is layered on above at (de)serialization.
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;

// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
// smoother on big transpositions, more onset latency. One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0;

// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
struct PitchEnvParams {
    bool         enabled       = false;
    std::int64_t attackFrames  = 0;
    std::int64_t decayFrames   = 0;
    double       peakSemitones = 0.0;  // signed depth at the peak
};

// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
struct ZonePlayParams {
    PlayMode       playMode = PlayMode::Gate;
    AdsrParams     adsr;                          // Gate: the AHDSR envelope
    TriggerParams  trigger;                       // Trigger: %-length + fades
    PitchEngine    pitchEngine = PitchEngine::Varispeed;
    PitchEnvParams pitchEnv;                       // AD pitch modulation, off by default
};

// ---------------------------------------------------------------------------
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
// govern playback. The shell decodes the on-disk WAV and fills this; the core
// never touches a file.
// ---------------------------------------------------------------------------

// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
// is the "no sustain loop" marker — a held note past the sample end goes silent
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
struct SampleLoop {
    bool hasLoop = false;
    std::int64_t start = 0;  // first looped frame (inclusive)
    std::int64_t end   = 0;  // one-past-last looped frame (exclusive); start <= end
};

// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
struct SampleData {
    std::vector<AudioSample> frames;   // channel 0 PCM (mono, or L of a stereo sample)
    std::vector<AudioSample> framesR;  // channel 1 PCM (R); EMPTY for a mono sample
    int sampleRate = 0;                // frames per second (for reference; ratio is
                                       // note-relative, so rate cancels for repitch).
                                       // 0 is explicitly invalid — every consumer must
                                       // receive a real rate before use.
    int rootNote = 60;                 // MIDI note recorded at (plays at unity here)
    SampleLoop loop;                   // sustain loop, if any
    // Initial read position (frame offset) a voice starts playback at — frame 0 by
    // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
    // an instrument-side per-zone override (the "start point" marker); S15 builds on it
    // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
    // a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
    std::int64_t startFrame = 0;

    // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
    // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
    // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
    ZonePlayParams play;

    // 2 iff a matching-length second channel exists; else 1. A framesR of a different
    // length than frames is treated as absent (mono) — a malformed pair never half-plays.
    int channelCount() const {
        return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
    }

};

// ---------------------------------------------------------------------------
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
// to at most one zone; a zone names which SampleData to play and the root note to
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
// sample's own root. Tier-1: several zones, each a key range with its own root.
//
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
// resolve() gains the velocity dimension it already receives but currently ignores
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
// sample within it" shape are already in place — Tier 2 fills in the second step
// without changing callers or the voice engine. See the report note.
// ---------------------------------------------------------------------------

// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
// the root note to repitch from (defaults to the sample's own root, overridable in
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
struct KeyZone {
    int lowNote = 0;
    int highNote = 127;
    int rootNote = 60;        // repitch reference for this zone
    // S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
    // standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
    // key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
    // in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
    double keyTrack = 1.0;
    std::size_t sampleIndex = 0;  // index into Keymap::samples
};

// Result of resolving a (note, velocity). `matched == false` means the note falls in
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
struct ZoneResolution {
    bool matched = false;
    std::size_t zoneIndex = 0;   // valid only when matched
};

// The keymap: the decoded samples plus the zones that map keys onto them. Owns
// resolution. Pure: no host types. Zones are tested first-match in order, so an
// earlier zone wins an overlap (deterministic, documented).
struct Keymap {
    std::vector<SampleData> samples;
    std::vector<KeyZone> zones;

    // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
    // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
    // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
    // the note.
    ZoneResolution resolve(int note, int velocity) const;

    // Convenience: build the Tier-0 degenerate keymap — one sample mapped
    // chromatically across the whole keyboard from its own root note.
    static Keymap singleSampleChromatic(SampleData sample);
};

// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
double pitchRatio(int note, int rootNote);

// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
// far playback pitch tracks the keyboard around the root:
//   keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
//                      (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
//   keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
//   keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
// the voice's baseRatio_.
double keyTrackedRatio(int note, int rootNote, double keyTrack);

// ---------------------------------------------------------------------------
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
//
// Segment math (all linear ramps):
//   Attack:  0 -> 1 over attackFrames
//   Hold:    hold 1 over holdFrames                     (S15: NEW stage between A and D)
//   Decay:   1 -> sustainLevel over decayFrames
//   Sustain: hold sustainLevel until noteOff
//   Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
// ---------------------------------------------------------------------------

class AdsrEnvelope {
public:
    enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };

    void configure(const AdsrParams& params) { params_ = params; }

    // Gate on: (re)start from Attack.
    void noteOn();
    // Gate off: enter Release from the current level.
    void noteOff();

    // Advances one frame and returns the amplitude for THIS frame (before advancing).
    // Once Release completes the envelope latches Finished and returns 0.0 forever
    // (until the next noteOn). A single, monotonic per-frame step — the caller pulls
    // one value per output frame.
    double tick();

    Stage stage() const { return stage_; }
    bool finished() const { return stage_ == Stage::Finished; }
    double level() const { return level_; }

private:
    AdsrParams params_;
    Stage stage_ = Stage::Idle;
    double level_ = 0.0;
    std::int64_t framesInStage_ = 0;
    double releaseFrom_ = 0.0;  // level at the moment noteOff() was called
};

// ---------------------------------------------------------------------------
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
// FadeCurve value structs are defined above with the other per-zone params.
// ---------------------------------------------------------------------------

// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
// output and source frames coincide, but under Varispeed a transposed voice consumes source
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
class TriggerEnvelope {
public:
    // Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
    // SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
    // (fadeOut anchored to the end). A zero/negative play length finishes immediately.
    void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
                   std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);

    // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
    // span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
    // the offset (no internal advance) so it composes with either pitch engine's read rate.
    double amplitudeAt(double sourceOffset);

    bool finished() const { return finished_; }

private:
    std::int64_t playLength_ = 0;
    std::int64_t fadeIn_ = 0;
    std::int64_t fadeOut_ = 0;
    FadeCurve curve_ = kDefaultFadeCurve;
    bool finished_ = false;
};

// ---------------------------------------------------------------------------
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
// ---------------------------------------------------------------------------

// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
class PitchEnvelope {
public:
    void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
    void noteOn() { pos_ = 0; }

    // Advance one frame, return this frame's pitch offset in semitones.
    double tick();

private:
    PitchEnvParams params_;
    std::int64_t pos_ = 0;
};

// ---------------------------------------------------------------------------
// A single voice: one active note playing one repitched, enveloped sample. 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:
    // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
    // the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
    // from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
    // sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
    // buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
    // Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
    // The 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(). The warm silence pass settles the OLA taps before the first
    // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
    // is default (Gate + Varispeed + no pitch env).
    // `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
    // 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
    void start(int note, int velocity, const SampleData& sample, int rootNote,
               double keyTrack = 1.0);

    // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
    // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
    void release();

    // True while this voice is producing (or about to produce) sound.
    bool active() const { return active_; }
    // The note this voice was started on (for note-off routing). Meaningless if idle.
    int note() const { return note_; }
    // Monotonic age counter — higher = started earlier relative to others. The voice
    // engine uses this for its stealing policy (oldest first). 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 S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
    // meaningful while active().
    PitchEngine pitchEngine() const { return pitchEngine_; }

    // Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
    // audio thread (this allocates). The engine calls it once at construction so start() — which
    // runs on the audio thread inside process() — never allocates: start() only reset()s + warm()s
    // the already-sized rings. `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
    // instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap no-op
    // in the underlying vector.
    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. The value is already velocity- and
    // envelope-scaled — the engine sums voices directly. This is the MONO path (channel
    // 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
    AudioSample renderFrame();

    // STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
    // the read head + envelope by exactly one frame (the same single advance the mono path
    // performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
    // sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
    // Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
    // as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
    void renderFrameStereo(AudioSample& l, AudioSample& r);

private:
    // 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 (Varispeed read-rate bias OR Preserve shift), advances the head, and
    // latches idle on exhaustion. `stereo` selects whether the second channel is read (and
    // returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
    AudioSample advanceFrame(bool stereo, AudioSample& outR);

    // This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
    // output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
    // fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
    // source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
    // finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
    double tickAmplitude();

    bool active_ = false;
    bool releasing_ = false;
    int note_ = 0;
    double velocityGain_ = 1.0;
    double baseRatio_ = 1.0;     // 2^((note-root)/12): the un-modulated repitch ratio
    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;

    // S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
    // one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
    // stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
    PlayMode playMode_ = PlayMode::Gate;
    AdsrEnvelope env_;
    TriggerEnvelope trigEnv_;
    std::int64_t startFrame_ = 0;  // clamped initial read frame; Trigger fade offset origin
    std::int64_t playEnd_ = 0;   // Trigger: source-frame end; Gate: unused
    bool amplitudeDone_ = false; // set when the active amplitude envelope finished

    // S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
    // (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
    // (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
    PitchEngine pitchEngine_ = PitchEngine::Varispeed;
    PitchEnvelope pitchEnv_;
    PitchShifter shiftL_;
    PitchShifter shiftR_;

    std::uint64_t startOrder_ = 0;
};

// ---------------------------------------------------------------------------
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
//
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
// new note-on arrives, steal in this priority order:
//   1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
//   2. else the oldest voice overall (longest-held note gives way to the new one).
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------

class VoiceEngine {
public:
    // Builds an engine with `maxVoices` voices (the polyphony bound) playing from
    // `keymap`. The keymap must outlive the engine (the engine holds a reference — it
    // reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
    // + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
    // from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
    // `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
    // shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
    // dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
    // maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
    // Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
    // note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
    // Varispeed-only instrument pays no ring cost). The processor derives it from the host
    // sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
    // are unaffected.
    VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
                std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);

    // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
    // zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
    // voice, or steals one per the policy above. Returns the index of the voice used,
    // or kNoVoice for an out-of-zone (unplayed) note.
    std::size_t noteOn(int note, int velocity);

    // MIDI note-off. Releases the most-recently-started active, non-releasing voice
    // playing `note` (so a re-triggered same note releases the newest first, leaving
    // the older tail to ring — matches hardware behavior). No-op if none match.
    void noteOff(int note);

    // REAL-TIME render (S4): sums all active voices into the caller-provided buffer
    // `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
    // this never touches memory it does not own and NEVER allocates). This is the
    // audio-thread entry point: the VST3 process callback passes the host's own output
    // channel buffer, so no allocation, resize, or heap traffic happens under process.
    // Voices that finish mid-block go idle and stop contributing. `out` must point at
    // at least `frameCount` writable samples; a null `out` or zero count is a no-op.
    void render(AudioSample* out, std::size_t frameCount);

    // REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
    // buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
    // (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
    // resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
    // a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
    // and stereo render paths are independent output shapes over the SAME voice pool; the active
    // channel mode (mono vs stereo bus) picks which one the process callback drives per block.
    void render(AudioSample* left, AudioSample* right, std::size_t frameCount);

    // TEST / off-thread convenience: appends `frameCount` summed frames to `out`
    // (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
    // real-time overload after sizing the buffer, so both paths share one mix loop.
    // Does not clear existing contents — appends, matching the pre-S4 contract the
    // unit tests rely on.
    void render(std::vector<AudioSample>& out, std::size_t frameCount);

    // Count of currently active voices (for tests / diagnostics).
    std::size_t activeVoiceCount() const;

    std::size_t maxVoices() const { return voices_.size(); }

    static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);

private:
    // Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
    // one per the documented policy. Always returns a valid index (maxVoices >= 1).
    std::size_t allocateVoice();

    // Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
    // (cheap: bounded by maxVoices) rather than maintained as a running tally.
    std::size_t activePreserveVoices() const;

    std::vector<Voice> voices_;
    const Keymap& keymap_;
    std::size_t preserveVoiceCap_ = 0;  // S16: max simultaneous Preserve voices (0 = no separate cap)
    std::uint64_t nextStartOrder_ = 1;  // monotonic; 0 reserved for "never started"
};

} // namespace reasampler
