loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+127 -4
View File
@@ -418,6 +418,7 @@ static void testGoldenFullBlobFixture() {
loopA.start = 1000;
loopA.end = 5000;
in.params.loopOverride = loopA;
in.params.loopCrossfadeFrames = 256;
in.params.startPoint = 250;
in.params.keyTrack = 0.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
@@ -449,7 +450,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,0x0a,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0b,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,
@@ -504,6 +505,8 @@ static void testGoldenFullBlobFixture() {
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
// --- payload v11 loop-crossfade tail ---
0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -551,13 +554,16 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 10);
CHECK(kParamsPayloadVersion == 11);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// 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.
// The filter, staged-curve and loop 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(kParamsPayloadVersion == kParamsLoopVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -701,6 +707,120 @@ static void testNonFiniteAhdSecondsLiftToZero() {
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}
// --- The loop tail (payload v11) ---------------------------------------------
// The loop span and its crossfade survive a save/reload intact, alongside the two overrides
// that share the record's head — a codec that read the crossfade into a neighbouring int64
// fails here rather than at the ear.
static void testLoopSpanAndCrossfadeRoundTrip() {
ComponentState in;
in.selectionId = "pad";
SampleLoop lp;
lp.hasLoop = true;
lp.start = 4096;
lp.end = 65536;
in.params.loopOverride = lp;
in.params.loopCrossfadeFrames = 1024;
in.params.startPoint = 512;
in.params.rootOverride = 55;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
CHECK(out.params.loopCrossfadeFrames == 1024);
CHECK(out.params.startPoint && *out.params.startPoint == 512);
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
}
// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it
// at the wire keeps the parameter set the editor reads back sane.
static void testNegativeCrossfadeOnTheWireLiftsToZero() {
ComponentState in;
in.selectionId = "pad";
in.params.loopCrossfadeFrames = -4096;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.loopCrossfadeFrames == 0);
}
// Payload tails are strict SUFFIXES by construction, so a vN blob IS the current writer's
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
// being a pure suffix, these would decode as garbage instead of as the documented lift.
static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64
static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles
static const std::size_t kFilterTailBytes =
1 + 4 * 8 + 1 + 3 * 8 + 5 * 8 + (4 + 2 * 2 * 8); // v9: the filter block + its 2-pt curve
static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state,
std::uint32_t pv, std::size_t cutBytes) {
std::vector<std::uint8_t> bytes = serializeComponentState(state);
bool stamped = false;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
if (m != kParamsFormatMarker) continue;
// Guard the naive marker scan: a false positive inside payload data would not be
// sitting in front of the CURRENT version.
CHECK(bytes[i + 4] == static_cast<std::uint8_t>(kParamsPayloadVersion));
bytes[i + 4] = static_cast<std::uint8_t>(pv);
stamped = true;
break;
}
CHECK(stamped);
CHECK(bytes.size() > cutBytes);
bytes.resize(bytes.size() - cutBytes);
return bytes;
}
// A project saved before this change reopens sounding identical: its loop span still applies
// and its seam is still hard, at EVERY prior single-record version.
static void testPriorPayloadVersionsLiftToAHardSeam() {
ComponentState in;
in.selectionId = "pad";
SampleLoop lp;
lp.hasLoop = true;
lp.start = 2000;
lp.end = 9000;
in.params.loopOverride = lp;
in.params.startPoint = 128;
in.params.keyTrack = 0.5;
in.params.play.adsr.releaseSeconds = 0.25;
// Set on the in-state only so a v10 lift can be checked to keep it and a v9 lift to drop
// it — proving the cuts land where the ladder says they do.
in.params.play.adsr.attackCurve = 4.0;
in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11
struct Case {
std::uint32_t pv;
std::size_t cut;
bool keepsCurveTail;
};
const Case cases[] = {
{10, kLoopTailBytes, true},
{9, kLoopTailBytes + kCurveTailBytes, false},
{8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false},
};
for (const Case& c : cases) {
const ComponentState out =
deserializeComponentState(payloadDowngradedTo(in, c.pv, c.cut), 48000.0);
// The span itself has been in the format since v2 and must survive untouched.
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000);
CHECK(out.params.loopOverride && out.params.loopOverride->end == 9000);
CHECK(out.params.startPoint && *out.params.startPoint == 128);
CHECK(out.params.keyTrack == 0.5);
CHECK(out.params.play.adsr.releaseSeconds == 0.25);
// The documented pre-change behaviour: a hard seam.
CHECK(out.params.loopCrossfadeFrames == 0);
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
CHECK(out.params.play.adsr.attackCurve ==
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
}
}
// 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.
@@ -1270,6 +1390,9 @@ int main() {
testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testLoopSpanAndCrossfadeRoundTrip();
testNegativeCrossfadeOnTheWireLiftsToZero();
testPriorPayloadVersionsLiftToAHardSeam();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testMigratedFadeContourTracksTheRetiredEqualPowerShape();