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:
2026-07-31 08:37:57 -04:00
parent 87d7ceb066
commit 13e8c5c4d9
51 changed files with 3406 additions and 1812 deletions
@@ -40,3 +40,7 @@ reasampler_test(sampler_filter LINK sampler_core)
# Live delivery is the third integration seam over the same engine: what a published block
# does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
+167 -111
View File
@@ -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_;
};
+3 -3
View File
@@ -11,10 +11,10 @@ LiveValues foldLive(const PlayParams& params) {
v.filterModAmount = params.filter.modAmount;
v.filterKeyTrack = params.filter.keyTrack;
v.filterEnv = params.filter.env;
v.filterAhd = params.filter.trigEnv;
v.adsr = params.adsr;
v.pitchEnvAttackFrames = params.pitchEnv.attackFrames;
v.pitchEnvDecayFrames = params.pitchEnv.decayFrames;
v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones;
v.ampAhd = params.trigAhd;
v.pitchEnv = params.pitchEnv;
return v;
}
+7 -3
View File
@@ -27,15 +27,19 @@ inline constexpr double kLiveRampSeconds = 0.020;
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
// the filter's prepare() than to splice it back; it changes only across a reload, which
// republishes this block, so the two can never disagree.
// Each envelope carries BOTH mode shapes: which one a voice applies is fixed at note-on by
// its play mode, so publishing both keeps the block one shape regardless of mode. The pitch
// envelope's `enabled` rides along inside its params only because the struct is carried whole;
// PitchEnvelope ignores it, since a toggle travels by reload.
struct LiveValues {
filter::FilterSettings filterSettings{};
double filterModAmount = 0.0;
double filterKeyTrack = 0.0;
AdsrParams filterEnv{};
AhdParams filterAhd{};
AdsrParams adsr{};
std::int64_t pitchEnvAttackFrames = 0;
std::int64_t pitchEnvDecayFrames = 0;
double pitchEnvPeakSemitones = 0.0;
AhdParams ampAhd{};
PitchEnvParams pitchEnv{};
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
+41 -24
View File
@@ -11,6 +11,7 @@
#include "core/audio/peaks.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/velocity_curve.h"
#include "core/util/curve_law.h" // the per-segment curve exponent domain + its neutral
namespace reasampler {
@@ -39,34 +40,44 @@ inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16;
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
// The three curve exponents shape the SLOPED stages only — Hold and Sustain are flat by
// definition and carry none. `curve_law.h` owns what an exponent means.
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t holdFrames = 0;
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
// the span left after attack and decay, never a time of its own — that is what makes
// A + H + D <= span structural rather than clamped (see fitAhd in envelopes.h).
struct AhdParams {
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double holdFraction = 1.0; // 0..1 of the span remaining after attack + decay
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// honor the start point. Default Gate so an instrument with no params set plays as before.
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
// the start point. Default Gate so an instrument with no params set plays as before.
enum class PlayMode { Gate, Trigger };
// Playback covers [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
// Trigger's play SPAN: [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). The voice frees when the head reaches playEnd.
// The amplitude SHAPE over that span is PlayParams::trigAhd — the fade-in/fade-out pair that
// used to live here is retired; do not reintroduce a second amplitude mechanism.
struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play
std::int64_t fadeInFrames = 0;
std::int64_t fadeOutFrames = 0;
double lengthFraction = 1.0; // (0,1] of the post-start span to play
};
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// the build-time residual.
enum class FadeCurve { EqualPower, Linear };
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
// (an octave up keeps its length).
@@ -83,14 +94,15 @@ inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
// bit-identical to the un-modulated engine). At note-on the offset rises to peakSemitones over
// attack, holds there, then falls to 0 over decay; a zero attack gives a pure percussive pitch
// drop. The hold fraction defaults to 0 so an instance predating the stage plays exactly as its
// attack-decay predecessor did.
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double peakSemitones = 0.0; // signed depth at the peak
bool enabled = false;
double peakSemitones = 0.0; // signed depth at the peak
AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
};
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
@@ -106,7 +118,11 @@ struct FilterParams {
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
AdsrParams env; // the same staged AHDSR the amp runs; frames
// The filter envelope takes the same shape the amp does under the active play mode:
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
// mode's dialled values (see core/instrument/CLAUDE.md).
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
// because a flat curve under a depth control would make every velocity the same offset;
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
@@ -121,8 +137,9 @@ struct FilterParams {
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
struct PlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr;
TriggerParams trigger;
AdsrParams adsr; // Gate amp
TriggerParams trigger; // Trigger play span
AhdParams trigAhd; // Trigger amp
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv;
FilterParams filter;
+33 -21
View File
@@ -64,11 +64,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
startFrame_ = start; // the span-offset origin: readPos - startFrame
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
// % play length.
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t trigSpan = 0;
if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr);
env_.noteOn();
@@ -79,17 +80,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
double frac = p.trigger.lengthFraction;
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
if (frac > 1.0) frac = 1.0;
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t playLen = static_cast<std::int64_t>(
static_cast<double>(span) * frac + 0.5); // round
static_cast<double>(postStart) * frac + 0.5); // round
if (playLen < 0) playLen = 0;
if (playLen > span) playLen = span;
if (playLen > postStart) playLen = postStart;
playEnd_ = start + playLen;
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
kDefaultFadeCurve);
trigSpan = playLen;
ampAhd_.configure(playLen, p.trigAhd);
}
pitchEnv_.configure(p.pitchEnv);
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point.
pitchEnv_.configure(postStart, p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
@@ -119,8 +121,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
if (playMode_ == PlayMode::Gate) {
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
} else {
filterAhd_.configure(trigSpan, p.filter.trigEnv);
}
filter_.reset();
updateFilterCutoffBase(note);
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
@@ -196,25 +202,31 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
// Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
// resolved at note-on and travels by reload instead (deck_groups.h names why).
// Each envelope applies only the shape its play mode selected at note-on; the block
// carries both so the mode never changes what is published.
//
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
const bool gate = (playMode_ == PlayMode::Gate);
if (snap) {
if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr);
pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(live.ampAhd);
pitchEnv_.snapLive(live.pitchEnv);
} else {
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
pitchEnv_.applyLive(live.pitchEnv);
}
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
if (snap) filterEnv_.snapLive(live.filterEnv);
else filterEnv_.applyLive(live.filterEnv);
if (snap) {
if (gate) filterEnv_.snapLive(live.filterEnv);
else filterAhd_.snapLive(live.filterAhd);
} else {
if (gate) filterEnv_.applyLive(live.filterEnv);
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
filterSettings_.morphLaw = live.filterSettings.morphLaw;
+49 -16
View File
@@ -60,7 +60,7 @@ inline double filterNormPerOctave() {
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero
// compensation and kept the full click — the difference-seed has no such hole. Off by
// default so the bare core stays byte-identical to the pre-fix engine; the processor
// shell opts in.
@@ -162,25 +162,26 @@ private:
}
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
// advanceFrame frees the voice.
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice.
double tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Anchored to the source offset so fades land on the same source frames under
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
// advanceFrame; finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
amp = ampAhd_.amplitudeAt(sourceOffset());
if (ampAhd_.finished()) amplitudeDone_ = true;
}
return amp;
}
// Frames into the Trigger play span at the current read head — the domain both
// sustain-less envelopes are evaluated over.
double sourceOffset() const { return readPos_ - static_cast<double>(startFrame_); }
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
// rather than staircasing. State preservation across the solve is voice_filter's own
@@ -195,8 +196,13 @@ private:
// through both so a moved base always re-solves.
void tickFilterCutoff() {
if (filterModAmount_ == 0.0 && filterSolved_) return;
double cut = static_cast<double>(filterBaseCutoff_) +
filterModAmount_ * filterEnv_.tick();
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted.
const double envOut = (playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset());
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0;
const float cutNorm = static_cast<float>(cut);
@@ -284,6 +290,22 @@ private:
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
// Rings the voice's last rendered output out instead of hard-cutting it when the read head
// reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real
// source content at its natural end and its stop is left byte-identical; Preserve's is
// recycled synthetic tail (freezeTail stops the writer a full window before the read head
// arrives), whose level bears no relation to the source's own ending — cutting it at
// whatever amplitude the splice machinery happens to be at is the end-of-sample click.
// Reuses the takeover blend so the boundary frame reproduces the last level exactly.
void seedTerminalDeclick() {
if (pitchEngine_ != PitchEngine::Preserve) return;
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
declickWeight_ = 1.0;
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
// Shared read/advance for both render paths: computes the interpolated per-channel
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
@@ -327,6 +349,7 @@ private:
// byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick();
if (!declickActive_) seedTerminalDeclick();
if (declickActive_) {
// Bounded blend at silence: outCurrent == 0, so the blend is
// w*(ref 0) == w*ref. The weight decays by kDeclickDecay each frame,
@@ -350,6 +373,15 @@ private:
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
const double amp = tickAmplitude();
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
// still holds the PREVIOUS frame — this one is already silent. Gate is left out on
// purpose: its amplitude reaches zero through a release, so there is no cut to ring out.
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
playMode_ == PlayMode::Trigger) {
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
@@ -518,13 +550,13 @@ private:
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
// Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
// readPos_ >= playEnd_).
PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_;
TriggerEnvelope trigEnv_;
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
AhdEnvelope ampAhd_;
std::int64_t startFrame_ = 0; // clamped initial read frame; the span-offset origin
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
@@ -534,7 +566,8 @@ private:
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
instrument::engine::filter::VoiceFilter filter_;
AdsrEnvelope filterEnv_;
AdsrEnvelope filterEnv_; // Gate
AhdEnvelope filterAhd_; // Trigger
bool filterOn_ = false;
double filterRate_ = 0.0;
double filterCutoffNorm_ = 1.0;