#pragma once // envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, sustain-less AHD, // AHD pitch offset). Concrete classes, every body defined in-class: these are called // per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop. // NEVER give them a common base or a virtual tick() — that vtable lands on the hottest // inner loop in the program (root CLAUDE.md, structural heuristic 3). #include #include #include "core/instrument/engine/play_params.h" #include "core/util/curve_law.h" namespace reasampler { using util::curveMap; // The A/H/D split of a bounded span, in frames. struct AhdSpan { std::int64_t attack = 0; std::int64_t hold = 0; std::int64_t decay = 0; std::int64_t total = 0; // attack + hold + decay; <= span by construction }; // THE span split, shared by every sustain-less envelope so they cannot disagree about where a // stage boundary is. Attack takes at most the whole span and Decay at most what Attack left, // so `remaining` is non-negative without a clamp; Hold then takes its FRACTION of that // remainder, which is why total <= span holds for every (attack, decay, fraction) triple and // there is no sum to clamp. The two per-stage mins reproduce the retired Trigger fade clamp // exactly (head first, tail into what is left), so a migrated instance keeps its stage lengths. inline AhdSpan fitAhd(std::int64_t spanFrames, const AhdParams& p) { AhdSpan out; const std::int64_t span = spanFrames > 0 ? spanFrames : 0; std::int64_t a = p.attackFrames > 0 ? p.attackFrames : 0; if (a > span) a = span; std::int64_t d = p.decayFrames > 0 ? p.decayFrames : 0; if (d > span - a) d = span - a; const std::int64_t remaining = span - a - d; double frac = p.holdFraction; if (!(frac > 0.0)) frac = 0.0; // also catches NaN if (frac > 1.0) frac = 1.0; out.attack = a; out.decay = d; out.hold = static_cast(static_cast(remaining) * frac + 0.5); out.total = out.attack + out.hold + out.decay; return out; } // The AHD's normalized level at `offset` frames into the span: 0 -> 1 over attack, flat 1 // across hold, 1 -> 0 over decay, 0 outside. Pure over the offset so both the ticking pitch // envelope and the positional amplitude one read one shape. inline double ahdLevelAt(double offset, const AhdSpan& s, double attackCurve, double decayCurve) { if (offset < 0.0 || offset >= static_cast(s.total)) return 0.0; if (s.attack > 0 && offset < static_cast(s.attack)) { return curveMap(offset / static_cast(s.attack), attackCurve); } const double decayStart = static_cast(s.total - s.decay); if (s.decay > 0 && offset >= decayStart) { double t = (offset - decayStart) / static_cast(s.decay); if (t > 1.0) t = 1.0; return 1.0 - curveMap(t, decayCurve); } return 1.0; } // Absorbs a step a live parameter move would otherwise put straight into an evaluator's // output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue // and the smoother's own branch stays predictably false. Per-frame decay rather than a // wall-clock one, matching the voice's takeover declick; the floor is far below both domains // this is used in (amplitude, and semitones of pitch offset). class StepSmoother { public: // `step` is (level before the change - level after it): adding it back reproduces the // pre-change output exactly on the first frame. void absorb(double step) { offset_ += step; active_ = (offset_ > kFloor || offset_ < -kFloor); if (!active_) offset_ = 0.0; } void clear() { offset_ = 0.0; active_ = false; } bool active() const { return active_; } // This frame's offset; decays afterwards, latching inactive at the floor. double advance() { const double out = offset_; offset_ *= kDecay; if (offset_ < kFloor && offset_ > -kFloor) { offset_ = 0.0; active_ = false; } return out; } private: static constexpr double kDecay = 0.95; static constexpr double kFloor = 1e-5; double offset_ = 0.0; bool active_ = false; }; // AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate: // noteOn() enters Attack; noteOff() enters Release from wherever it is. // // Segment math: // Attack: 0 -> 1 over attackFrames // Hold: hold 1 over holdFrames // 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 (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff // during attack/hold/decay releases from the current partial level, not from sustainLevel. // // stagePos_ is the elapsed position within the current stage. It is a double rather than a // frame count only so applyLive can hold a fractional normalized position; every value it // takes on the un-edited path is integral, so the segment math is bit-identical to the // integer-counter engine. 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() { stage_ = Stage::Attack; level_ = 0.0; stagePos_ = 0.0; smooth_.clear(); } // Gate off: enter Release from the CURRENT level — release-before-sustain releases from // the partial attack/decay level, not from sustainLevel. A running smoother deliberately // survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed. void noteOff() { if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) { return; // already released / not sounding. } releaseFrom_ = level_; stage_ = Stage::Release; stagePos_ = 0.0; } // Live parameter delivery to a fresh voice — one that has NOT yet rendered a frame, whose // latched copy may predate the newest edit. It takes the params outright: there is no // phase to hold and nothing to be continuous with. applyLive cannot serve here in either // direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED // stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0 // it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in. void snapLive(const AdsrParams& params) { params_ = params; smooth_.clear(); } // Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED // STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's // level is unchanged by construction and the remainder of the stage takes its share of the // newly-dialled duration. The rule is expressed over normalized position, never over // output level, so a per-segment curve exponent composes with it as a pure map of phi. // // Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step: // a sustain level moved while the voice holds it (sustain is a level, not a timed stage), // and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and // completes at its terminal level). void applyLive(const AdsrParams& params) { const double before = stageLevel(params_); const double oldDuration = stageDuration(params_); const double newDuration = stageDuration(params); if (newDuration > 0.0) { stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration) : newDuration; // a collapsed stage was complete } params_ = params; const double after = stageLevel(params_); if (after != before) smooth_.absorb(before - after); } // 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. // // While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying // (bounded by the step it absorbed). finished() ignores that residue, so a Release that // completes with an offset still decaying is hard-cut when the voice frees — the audible // remainder of a step the smoother had already taken most of. double tick() { const double out = tickStage(); return smooth_.active() ? out + smooth_.advance() : out; } Stage stage() const { return stage_; } bool finished() const { return stage_ == Stage::Finished; } double level() const { return level_; } private: // The level tick() would emit right now under `params` without advancing anything. THE one // home for every segment's shape: tickStage owns only the advance and the stage // transitions and reads its output from here, so a per-segment curve added later lands in // one place and the smoother can never size a step against a different curve than the // output takes. double stageLevel(const AdsrParams& params) const { switch (stage_) { case Stage::Attack: { if (params.attackFrames <= 0) return 1.0; double l = stagePos_ / static_cast(params.attackFrames); if (l > 1.0) l = 1.0; return curveMap(l, params.attackCurve); } case Stage::Hold: // A zero-length hold falls straight through to Decay on the next tick, whose // level at position 0 is 1.0 — unless decay is zero too, which lands on sustain. if (params.holdFrames > 0) return 1.0; return (params.decayFrames <= 0) ? params.sustainLevel : 1.0; case Stage::Decay: { if (params.decayFrames <= 0) return params.sustainLevel; double t = stagePos_ / static_cast(params.decayFrames); if (t > 1.0) t = 1.0; // never bites on the un-edited path (transitions at >=) return 1.0 + (params.sustainLevel - 1.0) * curveMap(t, params.decayCurve); } case Stage::Sustain: return params.sustainLevel; case Stage::Release: { if (params.releaseFrames <= 0) return 0.0; double t = stagePos_ / static_cast(params.releaseFrames); if (t > 1.0) t = 1.0; return releaseFrom_ * (1.0 - curveMap(t, params.releaseCurve)); } default: return 0.0; } } // The current stage's dialled duration under `params`; 0 for the untimed stages. double stageDuration(const AdsrParams& params) const { switch (stage_) { case Stage::Attack: return static_cast(params.attackFrames); case Stage::Hold: return static_cast(params.holdFrames); case Stage::Decay: return static_cast(params.decayFrames); case Stage::Release: return static_cast(params.releaseFrames); default: return 0.0; } } double tickStage() { switch (stage_) { case Stage::Idle: case Stage::Finished: level_ = 0.0; return 0.0; case Stage::Attack: { level_ = stageLevel(params_); const double out = level_; stagePos_ += 1.0; if (stagePos_ >= static_cast(params_.attackFrames)) { // holdFrames == 0 falls straight through Hold on the next tick to Decay. stage_ = Stage::Hold; stagePos_ = 0.0; level_ = 1.0; } return out; } case Stage::Hold: { // holdFrames <= 0 leaves the stage on this same tick (no frame consumed at // 1.0 beyond what Attack already emitted) so a zero-length hold emits no // extra sample. if (params_.holdFrames <= 0) { stage_ = Stage::Decay; stagePos_ = 0.0; level_ = 1.0; // Single re-dispatch into Decay (bounded: Hold->Decay only, not general // recursion). Re-enters the STAGE evaluator, never tick(), so a running // smoother is applied exactly once per frame. return tickStage(); } level_ = stageLevel(params_); const double out = level_; stagePos_ += 1.0; if (stagePos_ >= static_cast(params_.holdFrames)) { stage_ = Stage::Decay; stagePos_ = 0.0; level_ = 1.0; } return out; } case Stage::Decay: { level_ = stageLevel(params_); const double out = level_; stagePos_ += 1.0; if (stagePos_ >= static_cast(params_.decayFrames)) { stage_ = Stage::Sustain; stagePos_ = 0.0; level_ = params_.sustainLevel; } return out; } case Stage::Sustain: level_ = stageLevel(params_); return level_; case Stage::Release: { if (params_.releaseFrames <= 0) { level_ = 0.0; stage_ = Stage::Finished; return 0.0; } level_ = stageLevel(params_); const double out = level_; stagePos_ += 1.0; if (stagePos_ >= static_cast(params_.releaseFrames)) { stage_ = Stage::Finished; level_ = 0.0; } return out; } } return 0.0; // unreachable; silences a warning. } AdsrParams params_; Stage stage_ = Stage::Idle; double level_ = 0.0; double stagePos_ = 0.0; double releaseFrom_ = 0.0; // level at the moment noteOff() was called StepSmoother smooth_; }; // The sustain-less AHD amplitude shape, evaluated at a source-frame offset into the span // rather than by ticking output frames: under Varispeed a transposed voice consumes source // faster than output, so driving the shape off the read position keeps every stage boundary on // the same source frames regardless of engine. Note-off-immune and time-boxed by the span. // // Positional means there is no phase counter to hold across a live edit, so the phi rule // AdsrEnvelope applies has nothing to act on here; a live reshape is a level step, absorbed by // the same bounded smoother. class AhdEnvelope { public: // `spanFrames` is the bound the stages are fitted into — (playEnd - startFrame) for the // Trigger amp and filter envelopes. A zero/negative span finishes immediately. void configure(std::int64_t spanFrames, const AhdParams& params) { span_ = spanFrames > 0 ? spanFrames : 0; fit(params, /*latchFinished=*/false); // a fresh note starts from a clean read smooth_.clear(); } // Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape // outright, with no step to absorb. void snapLive(const AhdParams& params) { fit(params, /*latchFinished=*/false); smooth_.clear(); } // Live delivery to a sounding voice at its current `sourceOffset`. See the class note for // why this smooths rather than holding a normalized position. void applyLive(double sourceOffset, const AhdParams& params) { const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_); // LATCHED: a voice already read past its fitted total must never resurge because a // later live move reopened the total. Reachable on any active() voice, including one // ringing out past its own end (voice.h) where tickAmplitude() still runs. fit(params, /*latchFinished=*/true); const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_); if (after != before) smooth_.absorb(before - after); } // Amplitude at `sourceOffset` = (readPos - startFrame). Latches finished() at or past the // fitted total, which is what frees the voice. double amplitudeAt(double sourceOffset) { if (finished_ || sourceOffset >= static_cast(fit_.total)) { if (sourceOffset >= static_cast(fit_.total)) finished_ = true; return 0.0; } const double out = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_); return smooth_.active() ? out + smooth_.advance() : out; } bool finished() const { return finished_; } const AhdSpan& stages() const { return fit_; } private: // `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it — // see applyLive above for why. configure()/snapLive() pass false: those are a fresh read // (new note or a not-yet-rendered voice), which must compute finished_ from scratch. void fit(const AhdParams& p, bool latchFinished) { fit_ = fitAhd(span_, p); attackCurve_ = p.attackCurve; decayCurve_ = p.decayCurve; const bool empty = (fit_.total <= 0); finished_ = latchFinished ? (finished_ || empty) : empty; } std::int64_t span_ = 0; AhdSpan fit_; double attackCurve_ = util::kCurveNeutral; double decayCurve_ = util::kCurveNeutral; bool finished_ = true; StepSmoother smooth_; }; // tick() returns the current pitch offset in semitones (0 when disabled or past the AHD), // advancing one frame. The voice converts it to a ratio multiply (Varispeed) or a shift-amount // add (Preserve). Unlike the amplitude AHD this owns its own position counter — pitch-envelope // time is wall-clock output frames — so the mid-stage rule applies in full. class PitchEnvelope { public: // `spanFrames` is the playable span the Hold fraction is taken against. void configure(std::int64_t spanFrames, const PitchEnvParams& params) { span_ = spanFrames > 0 ? spanFrames : 0; params_ = params; fit_ = fitAhd(span_, params.shape); pos_ = 0.0; } void noteOn() { pos_ = 0.0; smooth_.clear(); } // Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice // that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete // toggle travelling by reload, so the caller's copy of it is deliberately ignored. // // Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is // an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a // live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact. void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) { span_ = spanFrames > 0 ? spanFrames : 0; params_.peakSemitones = params.peakSemitones; params_.shape = params.shape; fit_ = fitAhd(span_, params_.shape); smooth_.clear(); } // Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized // position within whichever leg the envelope is in, and absorb the depth step (peak is a // level, not a duration). A moved span re-fits under the same rule, so a live Pitch move // reshapes this envelope continuously instead of leaving it on the note-on read rate. void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) { const double before = offsetAt(); span_ = spanFrames > 0 ? spanFrames : 0; const AhdSpan next = fitAhd(span_, params.shape); pos_ = holdPhase(fit_, next); params_.peakSemitones = params.peakSemitones; params_.shape = params.shape; fit_ = next; const double after = offsetAt(); if (after != before) smooth_.absorb(before - after); } double tick() { if (!params_.enabled) return 0.0; const double offset = offsetAt(); pos_ += 1.0; return smooth_.active() ? offset + smooth_.advance() : offset; } private: // The semitone offset at the current position — the shared evaluator for both tick() and // applyLive's before/after comparison. double offsetAt() const { if (!params_.enabled) return 0.0; return params_.peakSemitones * ahdLevelAt(pos_, fit_, params_.shape.attackCurve, params_.shape.decayCurve); } // The position under `next` holding the normalized position within whichever leg pos_ is // in. A leg dialled to zero completes: the position lands on that leg's new end. double holdPhase(const AhdSpan& old, const AhdSpan& next) const { const double oa = static_cast(old.attack); const double oh = static_cast(old.hold); const double od = static_cast(old.decay); const double na = static_cast(next.attack); const double nh = static_cast(next.hold); const double nd = static_cast(next.decay); if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na; if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh; if (pos_ < oa + oh + od) { return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd; } return na + nh + nd; // already past the envelope: stay past it under the new shape } PitchEnvParams params_; std::int64_t span_ = 0; AhdSpan fit_; double pos_ = 0.0; StepSmoother smooth_; }; } // namespace reasampler