instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13

This commit is contained in:
2026-07-31 21:33:59 -04:00
parent f115904e4f
commit e44bd42dd9
33 changed files with 1739 additions and 364 deletions
+33 -7
View File
@@ -25,6 +25,7 @@ namespace reasampler {
using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::SplineCursor;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
@@ -164,14 +165,24 @@ public:
}
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: the AHD
// The read head as a fraction of the whole sample — the domain every spline EG is a pure
// function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value.
double splinePhase() const { return readPos_ * splineScale_; }
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per 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) {
if (ampSplineCur_.active()) {
// A contour covers the sample end to end, so the head leaving the span IS the end of
// the note — the exhaustion path in advanceFrame is what frees the voice.
amp = ampSplineCur_.eval(splinePhase());
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
@@ -202,9 +213,11 @@ private:
// 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());
const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase())
: ((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;
@@ -394,7 +407,9 @@ private:
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
const double pitchEnvSemis = pitchSplineCur_.active()
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
// pow entirely — no per-frame transcendental on the common path.
@@ -585,6 +600,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_;
double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0;
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.