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
+68 -24
View File
@@ -191,36 +191,76 @@ static void testSustainLevelChangeGlides() {
}
static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
// No hold stage, so the shape is the attack-decay one the pre-AHD envelope had.
PitchEnvParams p;
p.enabled = true;
p.attackFrames = 0;
p.decayFrames = 1000;
p.peakSemitones = 12.0;
p.shape.attackFrames = 0;
p.shape.decayFrames = 1000;
p.shape.holdFraction = 0.0;
PitchEnvelope a, b;
a.configure(p);
b.configure(p);
a.configure(100000, p);
b.configure(100000, p);
a.noteOn();
b.noteOn();
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
b.applyLive(0, 2000, 12.0); // decay doubled mid-decay
PitchEnvParams longer = p;
longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
// the edit is exactly what the unedited peer emits.
PitchEnvelope c, d;
c.configure(p);
d.configure(p);
c.configure(100000, p);
d.configure(100000, p);
c.noteOn();
d.noteOn();
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
c.applyLive(0, 1000, 0.0); // depth to zero mid-decay
PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
CHECK(c.tick() == 0.0);
}
// The pitch envelope's new middle stage, on the same phi rule: a hold dialled mid-hold keeps
// the level (flat by definition) and moves the boundary, and the fraction is taken against
// what attack and decay left rather than against the whole span.
static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
PitchEnvParams p;
p.enabled = true;
p.peakSemitones = 12.0;
p.shape.attackFrames = 100;
p.shape.decayFrames = 100;
p.shape.holdFraction = 0.5; // half of (1000 - 200) = 400 frames of hold
PitchEnvelope e;
e.configure(1000, p);
e.noteOn();
for (int i = 0; i < 100; ++i) e.tick(); // through the attack
CHECK(e.tick() == 12.0); // frame 100: at the peak, holding
for (int i = 0; i < 398; ++i) e.tick(); // to the last frame of the hold
CHECK(e.tick() == 12.0); // frame 499: still holding
CHECK(e.tick() == 12.0); // frame 500: decay's own first frame
CHECK(std::fabs(e.tick() - 12.0 * (1.0 - 1.0 / 100.0)) < 1e-12); // frame 501: descending
// A live hold change mid-hold is continuous (the stage is flat) and the envelope still
// finishes inside the span.
PitchEnvelope f;
f.configure(1000, p);
f.noteOn();
for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0;
f.applyLive(wider);
CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0);
}
// --- The fresh-note path: snap, never the phi rule ---------------------------------------
static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() {
@@ -258,9 +298,12 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams stale; // enabled, but every leg zero
stale.enabled = true;
PitchEnvelope env;
env.configure(stale);
env.configure(100000, stale);
env.noteOn();
env.snapLive(0, 1000, 12.0);
PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000;
env.snapLive(dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -412,25 +455,25 @@ static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
{"pitch env attack",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 48000;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.attackFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.attackFrames = 4000; }, -1},
{"pitch env decay",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.decayFrames = 8000; }, -1},
{"pitch env depth",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 480000;
s.play.pitchEnv.shape.decayFrames = 480000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvPeakSemitones = 0.0; }, -1},
[](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1},
};
for (const Case& c : cases) {
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
@@ -646,9 +689,9 @@ static void testPitchRatioAndVelocityGainStayLatched() {
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past
for (int blk = 0; blk < 8; ++blk) {
if (blk == 2) block.publish(hostile);
@@ -696,7 +739,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
filterSweep(rig);
rig.play.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.decayFrames = 24000;
rig.play.pitchEnv.shape.decayFrames = 24000;
rig.play.pitchEnv.peakSemitones = 3.0;
LiveValues hostile = foldLive(rig.play);
@@ -705,9 +748,9 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
hostile.filterModAmount = -1.0;
hostile.filterEnv.decayFrames = 4800;
hostile.filterEnv.sustainLevel = 0.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.sustainLevel = 0.4;
SampleData quiet = rig, loud = rig, untouched = rig;
@@ -743,6 +786,7 @@ int main() {
testShortenedStageStillLandsContinuously();
testSustainLevelChangeGlides();
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
testPitchEnvelopeHoldStagePlaysAndHoldsPhase();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep();