instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes
Trigger's fade pair folds into the AHD (and goes live); the release anchors right; Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#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
|
||||
// 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).
|
||||
@@ -9,9 +9,62 @@
|
||||
#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
|
||||
@@ -152,8 +205,9 @@ private:
|
||||
switch (stage_) {
|
||||
case Stage::Attack: {
|
||||
if (params.attackFrames <= 0) return 1.0;
|
||||
const double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
return l > 1.0 ? 1.0 : l;
|
||||
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
|
||||
@@ -162,16 +216,17 @@ private:
|
||||
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
|
||||
case Stage::Decay: {
|
||||
if (params.decayFrames <= 0) return params.sustainLevel;
|
||||
const double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
return 1.0 + (params.sustainLevel - 1.0) * t;
|
||||
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;
|
||||
const double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
const double l = releaseFrom_ * (1.0 - t);
|
||||
return l < 0.0 ? 0.0 : l;
|
||||
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;
|
||||
@@ -276,145 +331,146 @@ private:
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// 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;
|
||||
// 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);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
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_);
|
||||
fit(params);
|
||||
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 < 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;
|
||||
if (finished_ || sourceOffset >= static_cast<double>(fit_.total)) {
|
||||
if (sourceOffset >= static_cast<double>(fit_.total)) 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;
|
||||
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:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
void fit(const AhdParams& p) {
|
||||
fit_ = fitAhd(span_, p);
|
||||
attackCurve_ = p.attackCurve;
|
||||
decayCurve_ = p.decayCurve;
|
||||
finished_ = (fit_.total <= 0);
|
||||
}
|
||||
|
||||
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
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
// 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:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
|
||||
// `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 times and depth outright.
|
||||
void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) {
|
||||
params_.attackFrames = attackFrames;
|
||||
params_.decayFrames = decayFrames;
|
||||
params_.peakSemitones = peakSemitones;
|
||||
// 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.
|
||||
void snapLive(const PitchEnvParams& params) {
|
||||
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). `enabled` is a discrete toggle and travels by reload, so it is
|
||||
// deliberately not a parameter here.
|
||||
void applyLive(std::int64_t attackFrames, std::int64_t decayFrames,
|
||||
double peakSemitones) {
|
||||
const double before = offsetAt(params_);
|
||||
const double a = params_.attackFrames > 0 ? static_cast<double>(params_.attackFrames) : 0.0;
|
||||
const double d = params_.decayFrames > 0 ? static_cast<double>(params_.decayFrames) : 0.0;
|
||||
const double na = attackFrames > 0 ? static_cast<double>(attackFrames) : 0.0;
|
||||
const double nd = decayFrames > 0 ? static_cast<double>(decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
pos_ = (na > 0.0) ? pos_ * (na / a) : na;
|
||||
} else if (pos_ < a + d) {
|
||||
pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd;
|
||||
} else {
|
||||
pos_ = na + nd; // already past the envelope: stay past it under the new times
|
||||
}
|
||||
params_.attackFrames = attackFrames;
|
||||
params_.decayFrames = decayFrames;
|
||||
params_.peakSemitones = peakSemitones;
|
||||
const double after = offsetAt(params_);
|
||||
// level, not a duration).
|
||||
void applyLive(const PitchEnvParams& params) {
|
||||
const double before = offsetAt();
|
||||
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(params_);
|
||||
const double offset = offsetAt();
|
||||
pos_ += 1.0;
|
||||
return smooth_.active() ? offset + smooth_.advance() : offset;
|
||||
}
|
||||
|
||||
private:
|
||||
// The semitone offset at the current position under `params` — the shared evaluator for
|
||||
// both tick() and applyLive's before/after comparison.
|
||||
double offsetAt(const PitchEnvParams& params) const {
|
||||
if (!params.enabled) return 0.0;
|
||||
const double a = params.attackFrames > 0 ? static_cast<double>(params.attackFrames) : 0.0;
|
||||
const double d = params.decayFrames > 0 ? static_cast<double>(params.decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
return params.peakSemitones * (pos_ / a);
|
||||
// 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;
|
||||
}
|
||||
if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
return params.peakSemitones * (1.0 - (pos_ - a) / d);
|
||||
}
|
||||
return 0.0; // past attack+decay: at base pitch forever.
|
||||
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_;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user