Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs

This commit is contained in:
2026-08-01 20:26:04 -04:00
parent d3894dae6d
commit 19aeb92775
36 changed files with 1040 additions and 274 deletions
+136 -17
View File
@@ -21,6 +21,7 @@
using namespace reasampler;
using namespace reasampler::instrument::map;
namespace note = reasampler::instrument::note; // the bake Hold's ladder
static int failures = 0;
@@ -452,7 +453,7 @@ 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,0x0d,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0e,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,
@@ -545,6 +546,9 @@ static void testGoldenFullBlobFixture() {
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
// --- payload v14 bake Hold, at its one-bar default ---
0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1)
0x00, // Straight
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -592,18 +596,19 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 13);
CHECK(kParamsPayloadVersion == 14);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
// envelope ones — the two axes stay independent, so a future envelope field cannot collide
// with any of them on one number.
// The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps,
// not envelope ones — the two axes stay independent, so a future envelope field cannot
// collide with any of them on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -782,6 +787,19 @@ static void testNonFiniteAhdSecondsLiftToZero() {
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
// The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the
// hard flags and rewrite them without hand-counting the payload twice. Every velocity curve
// in those fixtures is at its default 2-point shape, which is what pins the flag block sizes.
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
// The v14 tail at its one-bar default, re-appended after a splice so the record still ends
// where the reader expects it to.
static void putDefaultBakeHoldTail(std::vector<std::uint8_t>& out) {
legacy::u32v(out, 2); // quarterExponent 2 == 1/1
legacy::u8v(out, 0); // Straight
}
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
@@ -814,8 +832,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
// order in params_payload.cpp) is deterministic and this test can splice it exactly.
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed 4+2-byte blocks
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u32v(bytes, 5); // amp: bogus count...
for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts
legacy::u32v(bytes, 2); // filter: correct count, unchanged
@@ -824,6 +842,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
putDefaultBakeHoldTail(bytes);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// Every param preceding AND following the corrupted amp tail survives untouched.
@@ -862,8 +881,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
in.params.loopCrossfadeFrames = 321;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
// filter/pitch blocks right after it land exactly where they belong.
@@ -873,6 +892,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
putDefaultBakeHoldTail(bytes);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// The whole record survives — including everything the v13 section itself carries ahead of
@@ -904,8 +924,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
in.params.loopCrossfadeFrames = 5;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
// Drops the bake-Hold tail with the flags: the truncation strands everything after it,
// which is the whole point — Hold lifts to its default alongside the flags.
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
legacy::u8v(bytes, 0x00);
@@ -918,6 +940,7 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
CHECK(out.params.loopCrossfadeFrames == 5);
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
}
// --- The loop tail (payload v11) ---------------------------------------------
@@ -1145,6 +1168,98 @@ static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
CHECK(back.params.play.filter.velAmount == -0.75);
}
// --- The bake Hold tail (payload v14) -----------------------------------------
// Hold survives a save/reload as its {rung, modifier} pair, and it is the ONLY field the v14
// bump touches — everything either side of it in the record comes back untouched.
static void testBakeHoldRoundTripsAndDisturbsNothingElse() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 33;
in.params.keyTrack = 0.25;
in.params.loopCrossfadeFrames = 64;
in.params.play.adsr.releaseSeconds = 0.31;
in.params.play.ampSpline.mode = EnvMode::Spline;
// A triplet on a rung well away from the default, so neither field can be read off the
// other's default and still pass.
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.bakeHold == note::makeDivision(-2, note::DivisionModifier::Triplet));
CHECK(out.params.bakeHold != InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 33);
CHECK(out.params.keyTrack == 0.25);
CHECK(out.params.loopCrossfadeFrames == 64);
CHECK(out.params.play.adsr.releaseSeconds == 0.31);
CHECK(out.params.play.ampSpline.mode == EnvMode::Spline);
}
// A v13 blob is a strict PREFIX of v14, so it must lift to the one-bar Hold default with
// every other field intact — the reason a project saved before Hold existed reopens the same.
static void testV13BlobLiftsToTheDefaultHold() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 55;
in.params.play.adsr.decaySeconds = 0.09;
in.params.play.filter.enabled = true;
in.params.loopCrossfadeFrames = 128;
in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);
// Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the
// previous binary would have written.
const std::vector<std::uint8_t> v13 =
payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes);
const ComponentState out = deserializeComponentState(v13, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
CHECK(out.params.play.adsr.decaySeconds == 0.09);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.loopCrossfadeFrames == 128);
}
// A corrupt rung/modifier pair clamps to the nearest legal division rather than being held as
// an unrepresentable one — makeDivision is the only door, and the codec goes through it.
static void testBakeHoldCorruptPairClampsToTheLadder() {
ComponentState in;
in.selectionId = "pad";
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes);
legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
legacy::u8v(bytes, 200); // an unnamed modifier byte
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold ==
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
}
// A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the
// revive a stray missing byte would reset every parameter ahead of it to defaults.
static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 71;
in.params.play.adsr.attackSeconds = 0.017;
in.params.loopCrossfadeFrames = 96;
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes);
legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing
legacy::u8v(bytes, 0x00);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
CHECK(out.params.play.adsr.attackSeconds == 0.017);
CHECK(out.params.loopCrossfadeFrames == 96);
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
@@ -1632,11 +1747,11 @@ static void testSampleRefsTruncatedMidEntry() {
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
// 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
// three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
// root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
// 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 643);
bytes.resize(bytes.size() - 643);
// three 6-byte hard-flag tails + the 5-byte v14 bake-Hold tail) = 628 bytes; entry two is
// 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 648 keeps
// the first 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 648);
bytes.resize(bytes.size() - 648);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1748,6 +1863,10 @@ int main() {
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
testBakeHoldRoundTripsAndDisturbsNothingElse();
testV13BlobLiftsToTheDefaultHold();
testBakeHoldCorruptPairClampsToTheLadder();
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;