// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
// render half is inline in voice.h by RT constraint — see that file's header.

#include "core/instrument/engine/voice.h"

#include <algorithm>

namespace reasampler {

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 voice simply never process()es shiftR_. The
    // prime scratch is sized here for the same reason: start() assembles the first window
    // of the upcoming source into it with zero allocation.
    shiftL_.configure(windowFrames);
    shiftR_.configure(windowFrames);
    primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
}

void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover,
                  double stretchRate, double keyTrack, double lengthFraction) {
    // Before any state reset, record the pre-cut reference (last rendered output) and mark
    // the compensation pending iff this start is a takeover/steal of a sounding voice and the
    // caller opted in. The ramp is seeded on the first frame rendered after the restart, from
    // the difference between this reference and the new voice's raw output that frame
    // (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
    // new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
    // restart whose new amplitude was instantly ~1 got zero compensation and kept the full
    // click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
    // deliberately not zeroed here: a second same-block takeover (two steals with no frame
    // rendered between) must record the same pre-cut reference, not a phantom 0.
    if (declickTakeover && active_) {
        // Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
        declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
        declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
        declickPending_ = true;
    } else {
        declickPending_ = false;
    }
    // Any in-flight ramp is superseded: pending re-derives from the reference, which already
    // includes the running declick's contribution via lastOut (it tracks post-declick output).
    declickActive_ = false;
    declickWeight_ = 0.0;

    active_ = true;
    releasing_ = false;
    amplitudeDone_ = false;
    note_ = note;
    // Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
    // velocityGain_.
    velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
    sample_ = &sample;

    const PlayParams& p = sample.play;
    // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than
    // costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate
    // bias and Preserve shift amount both derive from it below).
    velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
    pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones);
    playMode_ = p.playMode;
    pitchEngine_ = p.pitchEngine;
    // THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for
    // a rate this moves. Clamped once here so the read head's increment and the feed cursor's
    // debt accumulate the SAME value: they must stay exactly one window apart for the note's
    // whole life.
    stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
    // Keyed on the read path this note will ACTUALLY take, which is not the same question as
    // the stored engine: advanceFrame runs the Preserve branch only while the shifters are
    // configured, and a Preserve voice whose shifters were never sized falls back to the
    // varispeed read. Rate has to reach the increment there too, or that fallback would ignore
    // the control outright — the predicate is spelled the same way advanceFrame spells it.
    preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured();
    rateRatio_ = preserveRead_ ? 1.0 : stretchRate_;
    keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack;
    recomputeBaseRatio();
    // pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at
    // Pitch 0 it is a division by exactly 1.0.
    pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_;

    // 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.
    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;  // the span-offset origin: readPos - startFrame

    // The one fold of the stored span + crossfade into what the read path wraps on.
    loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
                                                  frameCount, playMode_ == PlayMode::Gate);

    // Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
    // the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
    // avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
    // spline branch entirely.
    splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
    if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
    else ampSplineCur_.clear();
    if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
        pitchSplineCur_.bind(p.pitchSpline.contour);
        pitchSplineDepth_ = p.pitchEnv.peakSemitones;
    } else {
        pitchSplineCur_.clear();
        pitchSplineDepth_ = 0.0;
    }
    if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
        filterSplineCur_.bind(p.filterSpline.contour);
    } else {
        filterSplineCur_.clear();
    }

    // Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
    // frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
    const std::int64_t postStart = frameCount - start;  // >= 1 (start clamped < frameCount)
    std::int64_t trigSpan = 0;
    if (playMode_ == PlayMode::Gate) {
        env_.configure(p.adsr);
        env_.noteOn();
        playEnd_ = 0;  // unused in Gate
    } else {
        // Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) —
        // map/trigger_seam.h's formula, evaluated inline because the engine does not depend on
        // map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot
        // fallback folds here, because a stored-but-inert %-knob must not shorten the span.
        double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction;
        if (!(frac > 0.0)) frac = 0.0;    // %=0 (or a corrupt NaN) -> finishes immediately
        if (frac > 1.0) frac = 1.0;
        std::int64_t playLen = static_cast<std::int64_t>(
            static_cast<double>(postStart) * frac + 0.5);  // round
        if (playLen < 0) playLen = 0;
        if (playLen > postStart) playLen = postStart;
        playEnd_ = start + playLen;
        trigSpan = playLen;
        ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd));
    }

    // The pitch AHD's Hold fraction is taken against the whole playable span, so its three
    // stages lay 1:1 over the waveform from the start point. The source->output conversion, and
    // why it is only first-order, are pitchEnvSpanFrames' own (voice.h).
    pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv);
    pitchEnv_.noteOn();

    // A restart lands every live glide back on the new note's own values, at a step derived
    // from this sample's rate rather than any assumed one.
    filterRamping_ = false;
    const double rampStep = instrument::engine::liveRampStep(
        static_cast<double>(sample.sampleRate));
    rBaseCutoff_.step = rampStep;
    rModAmount_.step = rampStep;
    rResonance_.step = rampStep;
    rMorph_.step = rampStep;
    rDrive_.step = rampStep;

    // Filter: reset() clears integrator state for the new note (prepare() preserves it —
    // voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
    // per-frame path, exactly as the amp's velocityGain_ does.
    filterOn_ = p.filter.enabled;
    if (filterOn_) {
        filterSettings_ = p.filter.settings;
        filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
        filterModAmount_ = p.filter.modAmount;
        filterKeyTrack_ = p.filter.keyTrack;
        filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
        filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
        filterRate_ = static_cast<double>(sample.sampleRate);
        rModAmount_.set(p.filter.modAmount);
        rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
        rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
        rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
        if (playMode_ == PlayMode::Gate) {
            filterEnv_.configure(p.filter.env);
            filterEnv_.noteOn();
        } else {
            filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv));
        }
        filter_.reset();
        updateFilterCutoffBase(note);
        // The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
        // a live move glides them, so every later re-solve is the cheap cutoff-only path. A
        // modulated voice supersedes this cutoff in tickFilterCutoff on its first frame,
        // before any sample reaches the kernel.
        instrument::engine::filter::FilterSettings s = p.filter.settings;
        s.cutoffNorm = filterBaseCutoff_;
        filter_.prepare(s, filterRate_);
        filterSolvedCutoff_ = filterBaseCutoff_;
        filterSolved_ = true;
    }

    // Prime the already-sized per-channel shifters with the first window of the actual
    // upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
    // the sample end, since that silence is the true stream there). The tap parks on source
    // frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
    // has a full window of real history to land in — a silence-warmed ring instead makes
    // every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
    // allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
    // allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
    // no per-frame shifter cost.
    if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
        const std::int64_t w = shiftL_.window();
        const bool loopWrap = loop_.active;
        const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
        // The prime may only carry playable source. The per-frame feed stops at feedBound
        // (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
        // writer there — but a full window bounded only by frameCount would let a Trigger
        // ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
        // transposed, before the voice freed), and a shorter-than-window sample would get
        // zero padding declared as valid history (splices landing in silence). So bound the
        // prime by the same playable span and, when that span is shorter than a window,
        // freeze the tail immediately after the prime — that machinery then recycles the
        // real short tail. The sustain-loop path is unbounded by construction (the wrap
        // keeps q inside the loop forever).
        const std::int64_t primeBound =
            (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
                ? playEnd_ : frameCount;
        const std::int64_t primeCount =
            loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
        // Both channels walk identical SOURCE positions (the walk depends only on loop
        // geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
        std::int64_t p = start;
        for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
            const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
            std::int64_t q = start;
            for (std::int64_t i = 0; i < primeCount; ++i) {
                if (loopWrap) {
                    while (q >= loop_.end) q -= loop_.length;
                }
                // q < frameCount holds by construction on the non-loop path (primeCount is
                // bounded); the guard stays as a belt for the loop-wrap walk. The prime runs
                // the SAME crossfade the per-frame feed does — a ring primed with an un-faded
                // seam would put the click back one window into the note.
                primeBuf_[static_cast<std::size_t>(i)] =
                    (q < frameCount)
                        ? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
                                                                            static_cast<double>(q)))
                        : 0.0f;
                ++q;
            }
            (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
            if (ch == 0) p = q;  // capture the end position once from channel 0's walk
        }
        // Per-frame feed continues at `p` (the feed bound when the prime exhausted the
        // playable span).
        stretch_.start(p);
        shiftL_.setFeedRate(stretchRate_);
        shiftR_.setFeedRate(stretchRate_);
        // Pitch-synchronous splices: the period was detected once at load (period_detect,
        // which this library deliberately does not link — the loader hands the answer down on
        // SampleData). 0 restores the fixed-window geometry, so a capture with no single
        // period plays exactly as it always did.
        shiftL_.setSourcePeriod(sample.sourcePeriodFrames);
        shiftR_.setSourcePeriod(sample.sourcePeriodFrames);
        if (!loopWrap && primeCount < w) {
            // Sub-window playable span: the source is already exhausted at prime time.
            shiftL_.freezeTail();
            if (stereoSample) shiftR_.freezeTail();
        }
    }
    ratio_ = baseRatio_;  // seeded; advanceFrame recomputes per frame under the active engine.
}

void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
    // Each envelope applies only the shape its play mode selected at note-on; the block
    // carries both so the mode never changes what is published.
    //
    // A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
    // flag: a voice that has rendered nothing has no phase to hold and nothing to be
    // continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
    //
    // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class,
    // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The
    // latched stretchRate_ is what stageFitRate carries into every conversion below, so a
    // stage-time move mid-note lands in this note's own rate domain rather than resetting it.
    const bool gate = (playMode_ == PlayMode::Gate);
    // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_
    // up as one more factor of next frame's read increment, Preserve as the shifter's transpose.
    // Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate
    // both of them are fitted against — a stale offset here would fit them to the previous move.
    pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones);
    recomputeBaseRatio();
    if (snap) {
        if (gate) env_.snapLive(live.adsr);
        else ampAhd_.snapLive(rateFittedAhd(live.ampAhd));
        pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv);
    } else {
        if (gate) env_.applyLive(live.adsr);
        else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd));
        pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv);
    }
    // The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
    // pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
    // which already glides through rModAmount_'s live ramp regardless of spline state (below),
    // this is the one place a live pitch-depth move must be re-applied by hand. Only meaningful
    // while pitchSplineCur_ is bound; harmless (and cheap) to set otherwise.
    pitchSplineDepth_ = live.pitchEnv.peakSemitones;
    if (!filterOn_) return;  // filter enable is a discrete toggle: it travels by reload

    if (snap) {
        if (gate) filterEnv_.snapLive(live.filterEnv);
        else filterAhd_.snapLive(rateFittedAhd(live.filterAhd));
    } else {
        if (gate) filterEnv_.applyLive(live.filterEnv);
        else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd));
    }
    filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
    filterKeyTrack_ = live.filterKeyTrack;
    // The note's curve value stays latched; only the depth over it is live. Both this and the
    // key-track depth land in the base cutoff, so they glide through rBaseCutoff_ below.
    filterVelOffset_ = live.filterVelAmount * filterVelCurve_;
    filterSettings_.morphLaw = live.filterSettings.morphLaw;
    const double baseTarget = filterCutoffBaseTarget(note_);
    if (snap) {
        rBaseCutoff_.set(baseTarget);
        rModAmount_.set(live.filterModAmount);
        rResonance_.set(static_cast<double>(live.filterSettings.resonanceNorm));
        rMorph_.set(static_cast<double>(live.filterSettings.morphNorm));
        rDrive_.set(static_cast<double>(live.filterSettings.driveNorm));
        filterBaseCutoff_ = static_cast<float>(baseTarget);
        filterModAmount_ = live.filterModAmount;
        filterRamping_ = false;
        prepareFilterFromRamps();
        return;
    }
    rBaseCutoff_.aim(baseTarget);
    rModAmount_.aim(live.filterModAmount);
    rResonance_.aim(static_cast<double>(live.filterSettings.resonanceNorm));
    rMorph_.aim(static_cast<double>(live.filterSettings.morphNorm));
    rDrive_.aim(static_cast<double>(live.filterSettings.driveNorm));
    filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() ||
                     rMorph_.moving() || rDrive_.moving();
}

void Voice::retune(int note) {
    // Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
    // running (no re-attack), the read head keeps its position, the shifter keeps its ring
    // (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
    // per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
    // legato phrase is one gesture, one strike (classic mono-synth behavior).
    if (!active_ || sample_ == nullptr) return;
    note_ = note;
    // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span
    // (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves
    // that envelope on the first note's domain — consistent with "touch nothing else," but the
    // drift lives here.
    // The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
    // one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved.
    recomputeBaseRatio();
    // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
    // too. The velocity offset deliberately stays the first note's, matching velocityGain_.
    if (filterOn_) updateFilterCutoffBase(note);
    // stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch
    // like velocityGain_, not a per-note property to re-resolve on a legato slide.
}

void Voice::release() {
    if (!active_) return;
    if (playMode_ == PlayMode::Trigger) return;  // Trigger ignores note-off, plays through
    releasing_ = true;
    env_.noteOff();
    filterEnv_.noteOff();
}

}  // namespace reasampler
