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
+28 -12
View File
@@ -566,8 +566,8 @@ static void testLegacyLiftDecision() {
// --- resolvePlay: stored SECONDS -> engine FRAMES at the live rate --------------
static void testResolvePlayConvertsWallClockAtTheRate() {
// Wall-clock times convert at the LIVE rate; source-timeline quantities (the Trigger
// %-length and its fades) carry through untouched, and levels/depths are not times.
// Wall-clock times convert at the LIVE rate; the Trigger %-length, every hold FRACTION and
// every curve exponent are rate-free and carry through untouched, as do levels and depths.
PlaySeconds st;
st.playMode = PlayMode::Trigger;
st.adsr.attackSeconds = 0.01;
@@ -575,13 +575,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
st.adsr.decaySeconds = 0.02;
st.adsr.sustainLevel = 0.8;
st.adsr.releaseSeconds = 0.15;
st.adsr.attackCurve = 2.5;
st.adsr.decayCurve = 0.4;
st.adsr.releaseCurve = 3.5;
st.trigger.lengthFraction = 0.75;
st.trigger.fadeInFrames = 441;
st.trigger.fadeOutFrames = 882;
st.trigAhd.attackSeconds = 0.01;
st.trigAhd.decaySeconds = 0.02;
st.trigAhd.holdFraction = 0.6;
st.trigAhd.attackCurve = 1.75;
st.trigAhd.decayCurve = 0.8;
st.pitchEngine = PitchEngine::Preserve;
st.pitchEnv.enabled = true;
st.pitchEnv.attackSeconds = 0.02;
st.pitchEnv.decaySeconds = 0.03;
st.pitchEnv.shape.attackSeconds = 0.02;
st.pitchEnv.shape.decaySeconds = 0.03;
st.pitchEnv.shape.holdFraction = 0.25;
st.pitchEnv.peakSemitones = 5.0;
const PlayParams at48 = resolvePlay(st, 48000);
@@ -591,13 +598,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at48.adsr.decayFrames == 960);
CHECK(at48.adsr.sustainLevel == 0.8); // a level, not a time
CHECK(at48.adsr.releaseFrames == 7200);
CHECK(at48.adsr.attackCurve == 2.5); // dimensionless
CHECK(at48.adsr.decayCurve == 0.4);
CHECK(at48.adsr.releaseCurve == 3.5);
CHECK(at48.trigger.lengthFraction == 0.75); // source-timeline, unconverted
CHECK(at48.trigger.fadeInFrames == 441);
CHECK(at48.trigger.fadeOutFrames == 882);
CHECK(at48.trigAhd.attackFrames == 480);
CHECK(at48.trigAhd.decayFrames == 960);
CHECK(at48.trigAhd.holdFraction == 0.6); // a fraction, not a time
CHECK(at48.trigAhd.attackCurve == 1.75);
CHECK(at48.trigAhd.decayCurve == 0.8);
CHECK(at48.pitchEngine == PitchEngine::Preserve);
CHECK(at48.pitchEnv.enabled);
CHECK(at48.pitchEnv.attackFrames == 960);
CHECK(at48.pitchEnv.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.attackFrames == 960);
CHECK(at48.pitchEnv.shape.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.holdFraction == 0.25);
CHECK(at48.pitchEnv.peakSemitones == 5.0); // a depth, not a time
// THE no-hardcoded-rate contract: the SAME stored seconds yield different frame counts
@@ -606,8 +620,10 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at96.adsr.attackFrames == 960);
CHECK(at96.adsr.holdFrames == 4800);
CHECK(at96.adsr.releaseFrames == 14400);
CHECK(at96.pitchEnv.attackFrames == 1920);
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
CHECK(at96.pitchEnv.shape.attackFrames == 1920);
CHECK(at96.trigAhd.attackFrames == 960);
CHECK(at96.trigAhd.holdFraction == 0.6); // still unconverted
CHECK(at96.adsr.attackCurve == 2.5);
}
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {