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:
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "../src/core/instrument/map/component_state_io.h"
|
||||
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
||||
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -285,12 +286,17 @@ static void testComponentStateRoundTrip() {
|
||||
in.params.play.adsr.sustainLevel = 0.8;
|
||||
in.params.play.adsr.releaseSeconds = 0.15;
|
||||
in.params.play.trigger.lengthFraction = 0.75;
|
||||
in.params.play.trigger.fadeInFrames = 441;
|
||||
in.params.play.trigger.fadeOutFrames = 882;
|
||||
in.params.play.trigAhd = AhdSeconds{0.011, 0.022, 0.65, 2.5, 0.4};
|
||||
in.params.play.adsr.attackCurve = 3.0;
|
||||
in.params.play.adsr.decayCurve = 0.3;
|
||||
in.params.play.adsr.releaseCurve = 6.0;
|
||||
in.params.play.filter.env.attackCurve = 1.25;
|
||||
in.params.play.filter.env.decayCurve = 0.75;
|
||||
in.params.play.filter.env.releaseCurve = 8.0;
|
||||
in.params.play.filter.trigEnv = AhdSeconds{0.033, 0.044, 0.15, 0.2, 9.0};
|
||||
in.params.play.pitchEngine = PitchEngine::Preserve;
|
||||
in.params.play.pitchEnv.enabled = true;
|
||||
in.params.play.pitchEnv.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.shape = AhdSeconds{0.02, 0.03, 0.45, 1.5, 0.6};
|
||||
in.params.play.pitchEnv.peakSemitones = 5.0;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
@@ -331,12 +337,31 @@ static void testComponentStateRoundTrip() {
|
||||
CHECK(p.play.adsr.sustainLevel == 0.8);
|
||||
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
||||
CHECK(p.play.trigger.lengthFraction == 0.75);
|
||||
CHECK(p.play.trigger.fadeInFrames == 441);
|
||||
CHECK(p.play.trigger.fadeOutFrames == 882);
|
||||
// Every curve exponent, hold fraction and Trigger AHD field survives the round trip
|
||||
// EXACTLY — the tail is doubles all the way down, so nothing quantizes.
|
||||
CHECK(p.play.adsr.attackCurve == 3.0);
|
||||
CHECK(p.play.adsr.decayCurve == 0.3);
|
||||
CHECK(p.play.adsr.releaseCurve == 6.0);
|
||||
CHECK(p.play.trigAhd.attackSeconds == 0.011);
|
||||
CHECK(p.play.trigAhd.decaySeconds == 0.022);
|
||||
CHECK(p.play.trigAhd.holdFraction == 0.65);
|
||||
CHECK(p.play.trigAhd.attackCurve == 2.5);
|
||||
CHECK(p.play.trigAhd.decayCurve == 0.4);
|
||||
CHECK(p.play.filter.env.attackCurve == 1.25);
|
||||
CHECK(p.play.filter.env.decayCurve == 0.75);
|
||||
CHECK(p.play.filter.env.releaseCurve == 8.0);
|
||||
CHECK(p.play.filter.trigEnv.attackSeconds == 0.033);
|
||||
CHECK(p.play.filter.trigEnv.decaySeconds == 0.044);
|
||||
CHECK(p.play.filter.trigEnv.holdFraction == 0.15);
|
||||
CHECK(p.play.filter.trigEnv.attackCurve == 0.2);
|
||||
CHECK(p.play.filter.trigEnv.decayCurve == 9.0);
|
||||
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
||||
CHECK(p.play.pitchEnv.enabled);
|
||||
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.shape.holdFraction == 0.45);
|
||||
CHECK(p.play.pitchEnv.shape.attackCurve == 1.5);
|
||||
CHECK(p.play.pitchEnv.shape.decayCurve == 0.6);
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
}
|
||||
|
||||
@@ -401,12 +426,10 @@ static void testGoldenFullBlobFixture() {
|
||||
in.params.play.adsr.sustainLevel = 0.8;
|
||||
in.params.play.adsr.releaseSeconds = 0.15;
|
||||
in.params.play.trigger.lengthFraction = 0.75;
|
||||
in.params.play.trigger.fadeInFrames = 100;
|
||||
in.params.play.trigger.fadeOutFrames = 200;
|
||||
in.params.play.pitchEngine = PitchEngine::Preserve;
|
||||
in.params.play.pitchEnv.enabled = true;
|
||||
in.params.play.pitchEnv.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.shape.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.shape.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.peakSemitones = 5.0;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
@@ -423,11 +446,11 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
||||
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
||||
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0a,0x00,0x00,
|
||||
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
|
||||
0xe8,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
|
||||
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
|
||||
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
|
||||
@@ -457,6 +480,27 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
|
||||
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
|
||||
// curve or AHD field), in the header's documented order ---
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp decay curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp release curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD attack 0.0 s
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD decay 0.0 s
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD hold 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD att curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD dec curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitch hold 0.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch attack curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch decay curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt attack curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt decay curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt release curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD attack 0.0 s
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD decay 0.0 s
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
@@ -504,12 +548,13 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||
}
|
||||
CHECK(kComponentStateVersion == 11);
|
||||
CHECK(kParamsPayloadVersion == 9);
|
||||
CHECK(kParamsPayloadVersion == 10);
|
||||
CHECK(kParamsSingleRecordVersion == 8);
|
||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||
// The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay
|
||||
// independent, so a future envelope field cannot collide with it on one number.
|
||||
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes
|
||||
// stay independent, so a future envelope field cannot collide with either on one number.
|
||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||
}
|
||||
|
||||
// --- The filter tail (payload v9) --------------------------------------------
|
||||
@@ -707,13 +752,46 @@ static void testSingleZoneMigrationIsLossless() {
|
||||
CHECK(p.play.adsr.sustainLevel == 0.8);
|
||||
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
||||
CHECK(p.play.trigger.lengthFraction == 0.75);
|
||||
CHECK(p.play.trigger.fadeInFrames == 100);
|
||||
CHECK(p.play.trigger.fadeOutFrames == 200);
|
||||
// The retired fade pair lifts onto the AHD that replaced it: attack <- fade-in, decay <-
|
||||
// fade-out (source frames over the project rate), hold <- the whole remainder.
|
||||
CHECK(p.play.trigAhd.attackSeconds == 100.0 / 48000.0);
|
||||
CHECK(p.play.trigAhd.decaySeconds == 200.0 / 48000.0);
|
||||
CHECK(p.play.trigAhd.holdFraction == 1.0);
|
||||
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
||||
CHECK(p.play.pitchEnv.enabled);
|
||||
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
// Everything the change added lifts to its own neutral, so the loaded instance plays as
|
||||
// the saved one did: every exponent linear, and the pitch envelope with no hold stage.
|
||||
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.trigAhd.attackCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.trigAhd.decayCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
|
||||
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
|
||||
}
|
||||
|
||||
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
|
||||
// stays representable under the AHD, which is what makes the consolidation lossless rather
|
||||
// than merely close.
|
||||
static void testZeroFadeOutMigratesToZeroDecay() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.trigger = true;
|
||||
z.lengthFraction = 1.0;
|
||||
z.fadeIn = 441;
|
||||
z.fadeOut = 0;
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 44100.0);
|
||||
const PlaySeconds& play = out.params.play;
|
||||
CHECK(play.playMode == PlayMode::Trigger);
|
||||
CHECK(play.trigAhd.attackSeconds == 441.0 / 44100.0);
|
||||
CHECK(play.trigAhd.decaySeconds == 0.0);
|
||||
CHECK(play.trigAhd.holdFraction == 1.0);
|
||||
}
|
||||
|
||||
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
|
||||
@@ -872,8 +950,8 @@ static void testLegacyV3FramesConvertAtTheProjectRate() {
|
||||
const ComponentState st = deserializeComponentState(out, 48000.0);
|
||||
CHECK(st.selectionId == "kick");
|
||||
CHECK(st.params.play.adsr.holdSeconds == 0.05);
|
||||
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(st.params.play.pitchEnv.shape.attackSeconds == 0.02);
|
||||
CHECK(st.params.play.pitchEnv.shape.decaySeconds == 0.03);
|
||||
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
|
||||
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
|
||||
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
|
||||
@@ -1001,11 +1079,11 @@ static void testSampleRefsTruncatedMidEntry() {
|
||||
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
||||
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
||||
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
|
||||
// v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop
|
||||
// 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid
|
||||
// loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 312);
|
||||
bytes.resize(bytes.size() - 312);
|
||||
// v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
|
||||
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
|
||||
// 27 of entry two's 47 — mid loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 472);
|
||||
bytes.resize(bytes.size() - 472);
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||
@@ -1087,6 +1165,7 @@ int main() {
|
||||
testEnvelopePrefixBytesFrozen();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
testZeroFadeOutMigratesToZeroDecay();
|
||||
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
||||
testLiftedStateReSavesInCurrentFormat();
|
||||
testMultiZoneMigrationAdoptsFirstZone();
|
||||
|
||||
Reference in New Issue
Block a user