loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user