// sampler_core — pure sampler engine implementation. See sampler_core.h for the
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.

#include "sampler_core.h"

#include <cmath>

namespace reasampler {

// ---------------------------------------------------------------------------
// pitchRatio
// ---------------------------------------------------------------------------

double pitchRatio(int note, int rootNote) {
    // Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
    return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
}

double keyTrackedRatio(int note, int rootNote, double keyTrack) {
    // Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
    // (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
    // to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
    // byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
    // every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
    const double semis = static_cast<double>(note - rootNote) * keyTrack;
    return std::pow(2.0, semis / 12.0);
}

// ---------------------------------------------------------------------------
// Keymap
// ---------------------------------------------------------------------------

ZoneResolution Keymap::resolve(int note, int velocity) const {
    (void)velocity;  // accepted for the Tier-2 seam; does not select at Tier 0-1.
    for (std::size_t i = 0; i < zones.size(); ++i) {
        const KeyZone& z = zones[i];
        if (note >= z.lowNote && note <= z.highNote) {
            return ZoneResolution{true, i};
        }
    }
    return ZoneResolution{false, 0};
}

Keymap Keymap::singleSampleChromatic(SampleData sample) {
    const int root = sample.rootNote;
    Keymap km;
    km.samples.push_back(std::move(sample));
    KeyZone zone;
    zone.lowNote = 0;
    zone.highNote = 127;
    zone.rootNote = root;
    zone.sampleIndex = 0;
    km.zones.push_back(zone);
    return km;
}

// ---------------------------------------------------------------------------
// AdsrEnvelope
// ---------------------------------------------------------------------------

void AdsrEnvelope::noteOn() {
    stage_ = Stage::Attack;
    level_ = 0.0;
    framesInStage_ = 0;
}

void AdsrEnvelope::noteOff() {
    if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
        stage_ == Stage::Release) {
        return;  // already released / not sounding.
    }
    // Release from the CURRENT level — release-before-sustain releases from the
    // partial attack/decay level, not from sustainLevel.
    releaseFrom_ = level_;
    stage_ = Stage::Release;
    framesInStage_ = 0;
}

double AdsrEnvelope::tick() {
    switch (stage_) {
        case Stage::Idle:
        case Stage::Finished:
            level_ = 0.0;
            return 0.0;

        case Stage::Attack: {
            if (params_.attackFrames <= 0) {
                level_ = 1.0;
            } else {
                level_ = static_cast<double>(framesInStage_) /
                         static_cast<double>(params_.attackFrames);
                if (level_ > 1.0) level_ = 1.0;
            }
            const double out = level_;
            ++framesInStage_;
            if (framesInStage_ >= params_.attackFrames) {
                // S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
                // through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
                stage_ = Stage::Hold;
                framesInStage_ = 0;
                level_ = 1.0;
            }
            return out;
        }

        case Stage::Hold: {
            // S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
            // stage on this same tick (no frame consumed at 1.0 beyond what Attack already
            // emitted), so hold=0 is byte-identical to the pre-S15 envelope.
            if (params_.holdFrames <= 0) {
                stage_ = Stage::Decay;
                framesInStage_ = 0;
                // Fall through to Decay this frame so no extra unity sample is emitted for a
                // zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
                level_ = 1.0;
                // Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
                return tick();
            }
            level_ = 1.0;
            const double out = level_;
            ++framesInStage_;
            if (framesInStage_ >= params_.holdFrames) {
                stage_ = Stage::Decay;
                framesInStage_ = 0;
                level_ = 1.0;
            }
            return out;
        }

        case Stage::Decay: {
            if (params_.decayFrames <= 0) {
                level_ = params_.sustainLevel;
            } else {
                const double t = static_cast<double>(framesInStage_) /
                                 static_cast<double>(params_.decayFrames);
                level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
            }
            const double out = level_;
            ++framesInStage_;
            if (framesInStage_ >= params_.decayFrames) {
                stage_ = Stage::Sustain;
                framesInStage_ = 0;
                level_ = params_.sustainLevel;
            }
            return out;
        }

        case Stage::Sustain:
            level_ = params_.sustainLevel;
            return level_;

        case Stage::Release: {
            if (params_.releaseFrames <= 0) {
                level_ = 0.0;
                stage_ = Stage::Finished;
                return 0.0;
            }
            const double t = static_cast<double>(framesInStage_) /
                             static_cast<double>(params_.releaseFrames);
            level_ = releaseFrom_ * (1.0 - t);
            if (level_ < 0.0) level_ = 0.0;
            const double out = level_;
            ++framesInStage_;
            if (framesInStage_ >= params_.releaseFrames) {
                stage_ = Stage::Finished;
                level_ = 0.0;
            }
            return out;
        }
    }
    return 0.0;  // unreachable; silences a warning.
}

// ---------------------------------------------------------------------------
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
// ---------------------------------------------------------------------------

void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
                                std::int64_t fadeOutFrames, FadeCurve curve) {
    playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
    curve_ = curve;
    finished_ = (playLength_ <= 0);

    // Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
    // negative fade is treated as 0. When both fades together exceed the play length, shrink
    // the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
    // then the fade-in — never letting either go negative or the sum exceed the span.
    std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
    std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
    if (fi > playLength_) fi = playLength_;
    if (fi + fo > playLength_) fo = playLength_ - fi;  // fo >= 0 since fi <= playLength_
    fadeIn_ = fi;
    fadeOut_ = fo;
}

double TriggerEnvelope::amplitudeAt(double sourceOffset) {
    if (finished_ || sourceOffset < 0.0 ||
        sourceOffset >= static_cast<double>(playLength_)) {
        // At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
        if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
        return 0.0;
    }

    // Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
    // Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
    // The offset is fractional (the read head is fractional under repitch), so the ramps are
    // smooth rather than stepped.
    double amp = 1.0;
    const double foStart = static_cast<double>(playLength_ - fadeOut_);
    if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
        const double phase = sourceOffset / static_cast<double>(fadeIn_);  // 0..1
        amp = (curve_ == FadeCurve::EqualPower)
                  ? std::sin(phase * 1.5707963267948966)  // sin(phase*pi/2): 0->1 constant power
                  : phase;
    } else if (fadeOut_ > 0 && sourceOffset >= foStart) {
        const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);  // 0..1
        amp = (curve_ == FadeCurve::EqualPower)
                  ? std::cos(phase * 1.5707963267948966)  // cos(phase*pi/2): 1->0 constant power
                  : (1.0 - phase);
    }
    return amp;
}

// ---------------------------------------------------------------------------
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
// ---------------------------------------------------------------------------

double PitchEnvelope::tick() {
    if (!params_.enabled) return 0.0;

    const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
    const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
    const double peak = params_.peakSemitones;

    double offset;
    if (pos_ < a) {
        // Attack: 0 -> peak over attackFrames (rise into the peak).
        offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
    } else if (pos_ < a + d) {
        // Decay: peak -> 0 over decayFrames (settle to base pitch).
        const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
        offset = peak * (1.0 - t);
    } else {
        offset = 0.0;  // past attack+decay: at base pitch forever.
    }
    ++pos_;
    return offset;
}

// ---------------------------------------------------------------------------
// Voice
// ---------------------------------------------------------------------------

void Voice::presizePreserveShifters(std::int64_t windowFrames) {
    // OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
    // no allocation at note-on; a mono Preserve voice simply never process()es shiftR_.
    shiftL_.configure(windowFrames);
    shiftR_.configure(windowFrames);
}

void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
                  double keyTrack) {
    active_ = true;
    releasing_ = false;
    amplitudeDone_ = false;
    note_ = note;
    // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively.
    int v = velocity;
    if (v < 0) v = 0;
    if (v > 127) v = 127;
    velocityGain_ = static_cast<double>(v) / 127.0;
    // S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
    // read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
    // the pre-S-VIEW-6 pitchRatio bit-for-bit.
    baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
    sample_ = &sample;

    const ZonePlayParams& p = sample.play;
    playMode_ = p.playMode;
    pitchEngine_ = p.pitchEngine;

    // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
    // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
    // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
    const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
    std::int64_t start = sample.startFrame;
    if (start < 0 || start >= frameCount) start = 0;
    readPos_ = static_cast<double>(start);
    startFrame_ = start;  // Trigger fade offset origin (readPos - startFrame = span offset)

    // --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
    //     play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
    //
    //     All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
    //     buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
    //     the live sample rate.
    //
    //     Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
    //     (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
    //     every DAW rate — now trivially true, since the times are wall-clock seconds. ---
    if (playMode_ == PlayMode::Gate) {
        env_.configure(p.adsr);
        env_.noteOn();
        playEnd_ = 0;  // unused in Gate
    } else {
        // Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
        double frac = p.trigger.lengthFraction;
        if (frac <= 0.0) frac = 0.0;      // %=0 -> zero play length (finishes immediately)
        if (frac > 1.0) frac = 1.0;
        const std::int64_t span = frameCount - start;  // >= 1 (start clamped < frameCount)
        std::int64_t playLen = static_cast<std::int64_t>(
            static_cast<double>(span) * frac + 0.5);  // round
        if (playLen < 0) playLen = 0;
        if (playLen > span) playLen = span;
        playEnd_ = start + playLen;
        trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
                           kDefaultFadeCurve);
    }

    // --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
    pitchEnv_.configure(p.pitchEnv);
    pitchEnv_.noteOn();

    // --- Preserve engine (S16): reset + pre-warm the ALREADY-SIZED per-channel shifters. The
    //      rings were allocated off-thread by presizePreserveShifters (the engine calls it at
    //     construction), so this RT-safe path only zeroes state (reset) and runs a silence pass
    //     (warm) to settle the OLA taps before the first output frame — NO allocation here.
    //     Varispeed voices never touch the shifters (advanceFrame checks configured()), so a
    //     Varispeed instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
    if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
        shiftL_.reset();
        shiftL_.warm();
        if (sample.channelCount() == 2 && shiftR_.configured()) {
            shiftR_.reset();
            shiftR_.warm();
        }
    }
    ratio_ = baseRatio_;  // seeded; advanceFrame recomputes per frame under the active engine.
}

void Voice::release() {
    if (!active_) return;
    // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
    if (playMode_ == PlayMode::Trigger) return;
    releasing_ = true;
    env_.noteOff();
}

double Voice::tickAmplitude() {
    double amp;
    if (playMode_ == PlayMode::Gate) {
        // AHDSR is wall-clock (one tick per output frame), independent of the read rate.
        amp = env_.tick();
        if (env_.finished()) amplitudeDone_ = true;
    } else {
        // Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
        // land on the same source frames under either engine's read rate. The voice ALSO frees on
        // readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
        amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
        if (trigEnv_.finished()) amplitudeDone_ = true;
    }
    return amp;
}

AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
    // Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
    // bracketing indices, interpolation partner) is computed ONCE and applied identically to
    // every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
    // per frame and scale all channels equally (a voice is one envelope). The head advances by
    // exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
    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, S15). If a
    // valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
    // it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
    // loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
    const SampleLoop& loop = sample_->loop;
    const bool loopUsable = playMode_ == PlayMode::Gate && loop.hasLoop &&
                            loop.end > loop.start && loop.start >= 0 && loop.end <= frameCount;
    if (loopUsable) {
        const double loopLen = static_cast<double>(loop.end - loop.start);
        while (readPos_ >= static_cast<double>(loop.end)) {
            readPos_ -= loopLen;  // wrap by exactly one loop length, preserving phase.
        }
    }

    // TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
    // trigger envelope also finishes at the same frame count; either latches the voice idle.
    const bool triggerRanOff =
        playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
    // Ran off the sample end with no usable loop -> voice is done.
    if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
        active_ = false;
        if (stereo) outR = 0.0f;
        return 0.0f;
    }

    // Linear interpolation between the two bracketing SOURCE frames. 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 (loopUsable && 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);

    // Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
    const double amp = tickAmplitude();
    const double gain = amp * velocityGain_;
    const double pitchEnvSemis = pitchEnv_.tick();

    // Raw interpolated source values (pre-shift). These are the SOURCE stream both engines read;
    // Varispeed applies pitch by the read RATE, Preserve applies it by the shifter.
    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;
    double srcR = 0.0;
    if (stereo) {
        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;
    }

    // The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
    // this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
    // (no per-frame transcendental), byte-identical to pre-S16.
    const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);

    double outL, outRlocal = 0.0;
    if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
        // PRESERVE: read the source at unity rate (duration held) and TRANSPOSE the output by
        // 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to the shift amount, not the
        // read rate — pitch bends, duration unchanged (S16 contract).
        const double shift = baseRatio_ * envFactor;
        shiftL_.setShiftRatio(shift);
        const double shiftedL = static_cast<double>(shiftL_.process(static_cast<AudioSample>(srcL)));
        outL = shiftedL * gain;
        if (stereo) {
            if (shiftR_.configured()) {
                // Genuine stereo: an independent shifter transposes channel 1. Each shifter is
                // process()'d EXACTLY ONCE per output frame (never twice — that would advance its
                // heads twice and corrupt the OLA state).
                shiftR_.setShiftRatio(shift);
                outRlocal =
                    static_cast<double>(shiftR_.process(static_cast<AudioSample>(srcR))) * gain;
            } else {
                // Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
                // value from srcL (== srcR since pcmR aliases pcm); mirror it to R. Do NOT call
                // shiftL_.process again this frame.
                outRlocal = shiftedL * gain;
            }
        }
        // Preserve advances the read head at the SOURCE rate (duration preserved).
        ratio_ = 1.0;
    } else {
        // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
        // envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
        // envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
        outL = srcL * gain;
        if (stereo) outRlocal = srcR * gain;
        ratio_ = baseRatio_ * envFactor;
    }

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

    readPos_ += ratio_;

    if (amplitudeDone_) {
        active_ = false;
    }
    return static_cast<AudioSample>(outL);
}

AudioSample Voice::renderFrame() {
    AudioSample discard = 0.0f;
    return advanceFrame(/*stereo=*/false, discard);
}

void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
    r = 0.0f;
    l = advanceFrame(/*stereo=*/true, r);
}

// ---------------------------------------------------------------------------
// VoiceEngine
// ---------------------------------------------------------------------------

VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
                         std::size_t preserveVoiceCap,
                         std::int64_t preserveWindowFrames)
    : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
      preserveVoiceCap_(preserveVoiceCap) {
    // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
    // clamp to a single voice so the engine is always usable (documented degenerate).
    //
    // Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
    // note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
    // allocation point for the shifter rings across the engine's lifetime.
    if (preserveWindowFrames > 1) {
        for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames);
    }
}

std::size_t VoiceEngine::activePreserveVoices() const {
    std::size_t n = 0;
    for (const Voice& v : voices_) {
        if (v.active() && v.pitchEngine() == PitchEngine::Preserve) ++n;
    }
    return n;
}

std::size_t VoiceEngine::allocateVoice() {
    // 1. A free (idle) voice, lowest index for determinism.
    for (std::size_t i = 0; i < voices_.size(); ++i) {
        if (!voices_[i].active()) return i;
    }
    // 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
    //    else the oldest voice overall. "Oldest" = smallest startOrder.
    std::size_t bestReleasing = kNoVoice;
    std::uint64_t bestReleasingOrder = 0;
    std::size_t bestOverall = kNoVoice;
    std::uint64_t bestOverallOrder = 0;
    for (std::size_t i = 0; i < voices_.size(); ++i) {
        const std::uint64_t order = voices_[i].startOrder();
        if (voices_[i].releasing()) {
            if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
                bestReleasing = i;
                bestReleasingOrder = order;
            }
        }
        if (bestOverall == kNoVoice || order < bestOverallOrder) {
            bestOverall = i;
            bestOverallOrder = order;
        }
    }
    return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
}

std::size_t VoiceEngine::noteOn(int note, int velocity) {
    const ZoneResolution res = keymap_.resolve(note, velocity);
    if (!res.matched) return kNoVoice;  // out-of-zone: defined no-play.

    const KeyZone& zone = keymap_.zones[res.zoneIndex];
    if (zone.sampleIndex >= keymap_.samples.size()) {
        return kNoVoice;  // zone points at a missing sample — refuse rather than UB.
    }
    const SampleData& sample = keymap_.samples[zone.sampleIndex];

    // S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
    // OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
    // rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
    // Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
    // NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
    if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
        activePreserveVoices() >= preserveVoiceCap_) {
        return kNoVoice;
    }

    // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
    // start() only reset()s + warm()s them — no allocation on this audio-thread path.
    const std::size_t v = allocateVoice();
    voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack);
    voices_[v].setStartOrder(nextStartOrder_++);
    return v;
}

void VoiceEngine::noteOff(int note) {
    // Release the NEWEST active, non-releasing voice on this note (largest startOrder),
    // so a re-triggered note releases its newest instance first and older tails ring.
    std::size_t target = kNoVoice;
    std::uint64_t bestOrder = 0;
    for (std::size_t i = 0; i < voices_.size(); ++i) {
        if (voices_[i].active() && !voices_[i].releasing() &&
            voices_[i].note() == note) {
            const std::uint64_t order = voices_[i].startOrder();
            if (target == kNoVoice || order > bestOrder) {
                target = i;
                bestOrder = order;
            }
        }
    }
    if (target != kNoVoice) voices_[target].release();
}

void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
    // Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
    // The VST3 process callback hands us the host's output channel buffer here, so the
    // audio thread never touches the heap (S4 real-time discipline).
    if (out == nullptr || frameCount == 0) return;
    for (Voice& voice : voices_) {
        if (!voice.active()) continue;
        for (std::size_t f = 0; f < frameCount; ++f) {
            if (!voice.active()) break;
            out[f] += voice.renderFrame();
        }
    }
}

void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
    // Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
    // contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
    // iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
    // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
    if (left == nullptr || right == nullptr || frameCount == 0) return;
    for (Voice& voice : voices_) {
        if (!voice.active()) continue;
        for (std::size_t f = 0; f < frameCount; ++f) {
            if (!voice.active()) break;
            AudioSample l = 0.0f, r = 0.0f;
            voice.renderFrameStereo(l, r);
            left[f] += l;
            right[f] += r;
        }
    }
}

void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
    // Off-thread / test path: grow the buffer (this allocates — never call under
    // process), zero-fill the appended span, then delegate to the RT mix loop so both
    // overloads share exactly one summation path.
    const std::size_t base = out.size();
    out.resize(base + frameCount, 0.0f);
    render(out.data() + base, frameCount);
}

std::size_t VoiceEngine::activeVoiceCount() const {
    std::size_t n = 0;
    for (const Voice& v : voices_) {
        if (v.active()) ++n;
    }
    return n;
}

} // namespace reasampler
