267 lines
11 KiB
C++
267 lines
11 KiB
C++
#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 <cmath>
|
|
#include <cstdint>
|
|
|
|
#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<double>(framesInStage_) /
|
|
static_cast<double>(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<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.
|
|
}
|
|
|
|
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<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): constant power
|
|
: phase;
|
|
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
|
const double phase = (sourceOffset - foStart) / static_cast<double>(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<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;
|
|
}
|
|
|
|
private:
|
|
PitchEnvParams params_;
|
|
std::int64_t pos_ = 0;
|
|
};
|
|
|
|
} // namespace reasampler
|