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:
@@ -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)
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user