#pragma once // envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade // shape, AD 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" namespace reasampler { // 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. 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; framesInStage_ = 0; } // Gate off: enter Release from the CURRENT level — release-before-sustain releases from // the partial attack/decay level, not from sustainLevel. void noteOff() { if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) { return; // already released / not sounding. } releaseFrom_ = level_; stage_ = Stage::Release; framesInStage_ = 0; } // 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() { 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(framesInStage_) / static_cast(params_.attackFrames); if (level_ > 1.0) level_ = 1.0; } const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.attackFrames) { // holdFrames == 0 falls straight through Hold on the next tick to Decay. stage_ = Stage::Hold; framesInStage_ = 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; framesInStage_ = 0; level_ = 1.0; // Single re-dispatch into Decay (bounded: Hold->Decay only, not 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(framesInStage_) / static_cast(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(framesInStage_) / static_cast(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. } 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 }; // A stateless-shape amplitude function over the play span, evaluated at a source-frame // offset into the span (not output frames): under Varispeed a transposed voice consumes // source faster than output, so driving the fades off the read position keeps fade-in/out // anchored to the same source frames regardless of engine. Distinct from AHDSR — // time-boxed by the play length and note-off-immune. class TriggerEnvelope { public: // `playLengthFrames` is (playEnd - startFrame). 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) { 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; } // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at // or past playLength. Pure over the offset so it composes with either pitch engine's // read rate. double amplitudeAt(double sourceOffset) { if (finished_ || sourceOffset < 0.0 || sourceOffset >= static_cast(playLength_)) { // At/past the play length the one-shot is done; the voice also frees on // readPos >= playEnd. if (sourceOffset >= static_cast(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(playLength_ - fadeOut_); if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { const double phase = sourceOffset / static_cast(fadeIn_); // 0..1 amp = (curve_ == FadeCurve::EqualPower) ? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power : phase; } else if (fadeOut_ > 0 && sourceOffset >= foStart) { const double phase = (sourceOffset - foStart) / static_cast(fadeOut_); amp = (curve_ == FadeCurve::EqualPower) ? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power : (1.0 - phase); } return amp; } 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; }; // tick() returns the current pitch offset in semitones (0 when disabled or past // attack+decay), advancing one frame. The voice converts it to a ratio multiply // (Varispeed) or a shift-amount add (Preserve). class PitchEnvelope { public: void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } void noteOn() { pos_ = 0; } double 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(pos_) / static_cast(a)); } else if (pos_ < a + d) { // Decay: peak -> 0 over decayFrames (settle to base pitch). const double t = static_cast(pos_ - a) / static_cast(d); offset = peak * (1.0 - t); } else { offset = 0.0; // past attack+decay: at base pitch forever. } ++pos_; return offset; } private: PitchEnvParams params_; std::int64_t pos_ = 0; }; } // namespace reasampler