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
+18 -12
View File
@@ -1023,17 +1023,20 @@ static void testAhdsrHoldZeroEqualsAdsr() {
}
// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
// * velocity directly. `attack`/`decay` are the AHD's ramp lengths in frames, with Hold taking
// the whole remainder — the shape that replaced the retired fade pair. Varispeed so no shift
// colours the amp.
static SampleData triggerSample(std::size_t frames, double lengthFraction,
std::int64_t fadeIn, std::int64_t fadeOut,
std::int64_t attack, std::int64_t decay,
std::int64_t startFrame = 0) {
SampleData s = dcSample(frames, 60);
s.startFrame = startFrame;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch
s.play.trigger.lengthFraction = lengthFraction;
s.play.trigger.fadeInFrames = fadeIn;
s.play.trigger.fadeOutFrames = fadeOut;
s.play.trigAhd.attackFrames = attack;
s.play.trigAhd.decayFrames = decay;
s.play.trigAhd.holdFraction = 1.0;
return s;
}
@@ -1181,10 +1184,13 @@ static void testPreserveDurationInvariance() {
const std::size_t atUp = lengthAt(72); // +12
const std::size_t atDown = lengthAt(48); // -12
// All three within a small tolerance of the source length (Preserve holds duration). The
// tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
CHECK(atUp >= frames - 20 && atUp <= frames + 20);
CHECK(atDown >= frames - 20 && atDown <= frames + 20);
// tolerance covers the shifter's fill/latency edge and the terminal ring-out Preserve ends
// on (voice.h's seedTerminalDeclick — bounded by the declick floor at ~185 frames), not a
// duration scaling, which would be 2x.
const std::size_t kTail = 200;
CHECK(atRoot >= frames - 20 && atRoot <= frames + kTail);
CHECK(atUp >= frames - 20 && atUp <= frames + kTail);
CHECK(atDown >= frames - 20 && atDown <= frames + kTail);
// The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would)
CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
@@ -1220,8 +1226,8 @@ static void testPitchEnvOffBitIdentical() {
if (withDisabledEnv) {
s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0)
s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 500;
}
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
@@ -1248,8 +1254,8 @@ static void testPitchEnvOnBendsVarispeed() {
SampleData s = sineSample(n, 40.0, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.shape.attackFrames = 0; // start at the peak
s.play.pitchEnv.shape.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
SampleData km = (std::move(s));
VoiceEngine eng(1, km);