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
+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;