493 lines
22 KiB
C++
493 lines
22 KiB
C++
#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 <cmath>
|
|
#include <cstdint>
|
|
|
|
#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<std::int64_t>(static_cast<double>(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<double>(s.total)) return 0.0;
|
|
if (s.attack > 0 && offset < static_cast<double>(s.attack)) {
|
|
return curveMap(offset / static_cast<double>(s.attack), attackCurve);
|
|
}
|
|
const double decayStart = static_cast<double>(s.total - s.decay);
|
|
if (s.decay > 0 && offset >= decayStart) {
|
|
double t = (offset - decayStart) / static_cast<double>(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<double>(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<double>(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<double>(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<double>(params.attackFrames);
|
|
case Stage::Hold: return static_cast<double>(params.holdFrames);
|
|
case Stage::Decay: return static_cast<double>(params.decayFrames);
|
|
case Stage::Release: return static_cast<double>(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<double>(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<double>(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<double>(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<double>(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<double>(fit_.total)) {
|
|
if (sourceOffset >= static_cast<double>(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<double>(old.attack);
|
|
const double oh = static_cast<double>(old.hold);
|
|
const double od = static_cast<double>(old.decay);
|
|
const double na = static_cast<double>(next.attack);
|
|
const double nh = static_cast<double>(next.hold);
|
|
const double nd = static_cast<double>(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
|