PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16
This commit is contained in:
@@ -453,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,0x0f,0x00,0x00,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x10,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,
|
||||
@@ -551,6 +551,9 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00, // Straight
|
||||
// --- payload v15 limiter enable ---
|
||||
0x00, // bypassed (the default)
|
||||
// --- payload v16 rate + baseline pitch offset ---
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // playRate 1.0
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitchOffsetSemitones 0.0
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
@@ -598,10 +601,10 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||
}
|
||||
CHECK(kComponentStateVersion == 11);
|
||||
CHECK(kParamsPayloadVersion == 15);
|
||||
CHECK(kParamsPayloadVersion == 16);
|
||||
CHECK(kParamsSingleRecordVersion == 8);
|
||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||
// The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode
|
||||
// The filter, staged-curve, loop, velocity, spline, bake-Hold, limiter and rate 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. This pins the NUMBERS only; that
|
||||
// each tail's bytes sit in the order its number implies is
|
||||
@@ -613,7 +616,8 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
||||
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
|
||||
CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsLimiterVersion);
|
||||
CHECK(kParamsRateVersion > kParamsLimiterVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsRateVersion);
|
||||
}
|
||||
|
||||
// --- The filter tail (payload v9) --------------------------------------------
|
||||
@@ -798,9 +802,14 @@ static void testNonFiniteAhdSecondsLiftToZero() {
|
||||
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
|
||||
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
|
||||
static constexpr std::size_t kLimiterTailBytes = 1;
|
||||
static constexpr std::size_t kRateTailBytes = 8 + 8; // v16: rate + pitch offset, two doubles
|
||||
// Everything past the hard flags, as ONE unit — the splice tests cut back over all of it, so a
|
||||
// new rung is one edit here rather than a hand-counted sum at each of them.
|
||||
static constexpr std::size_t kTrailingTailBytes =
|
||||
kBakeHoldTailBytes + kLimiterTailBytes + kRateTailBytes;
|
||||
|
||||
// The v14/v15 tails, re-appended after a splice so the record still ends where the reader
|
||||
// expects. They go back in wire order: Hold first, then the limiter byte.
|
||||
// The v14/v15/v16 tails, re-appended after a splice so the record still ends where the reader
|
||||
// expects. They go back in wire order: Hold, then the limiter byte, then the rate pair.
|
||||
static void putBakeHoldTail(std::vector<std::uint8_t>& out, int quarterExponent,
|
||||
note::DivisionModifier modifier) {
|
||||
legacy::u32v(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(quarterExponent)));
|
||||
@@ -811,9 +820,15 @@ static void putLimiterTail(std::vector<std::uint8_t>& out, bool enabled) {
|
||||
legacy::u8v(out, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
static void putRateTail(std::vector<std::uint8_t>& out, double rate, double pitchOffset) {
|
||||
legacy::f64v(out, rate);
|
||||
legacy::f64v(out, pitchOffset);
|
||||
}
|
||||
|
||||
static void putDefaultTrailingTails(std::vector<std::uint8_t>& out) {
|
||||
putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default
|
||||
putLimiterTail(out, false); // bypassed, the field's default
|
||||
putRateTail(out, 1.0, 0.0); // unity rate, no offset
|
||||
}
|
||||
|
||||
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
|
||||
@@ -848,8 +863,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() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
|
||||
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
|
||||
@@ -896,8 +911,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
|
||||
in.params.loopCrossfadeFrames = 321;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
|
||||
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
|
||||
// …and nothing at all after it, so the blob simply ends inside the v13 tail.
|
||||
|
||||
@@ -947,7 +962,7 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric
|
||||
// Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the
|
||||
// limiter byte — is exactly what the serializer wrote, which is the whole hazard.
|
||||
constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2);
|
||||
constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes;
|
||||
constexpr std::size_t kTrailingTails = kTrailingTailBytes;
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails);
|
||||
const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails;
|
||||
@@ -1044,10 +1059,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
|
||||
in.params.loopCrossfadeFrames = 5;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
|
||||
// Drops the bake-Hold and limiter tails with the flags: the truncation strands everything
|
||||
// after it, which is the whole point — both lift to their defaults alongside the flags.
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
|
||||
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
|
||||
legacy::u8v(bytes, 0x00);
|
||||
|
||||
@@ -1255,18 +1270,18 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact()
|
||||
CHECK(out.params.keyTrack == 0.25);
|
||||
CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));
|
||||
|
||||
// The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte
|
||||
// The same state stamped v14, with the two rungs appended after it cut away: byte-for-byte
|
||||
// what the Ξ binary wrote. Its Hold must survive in full.
|
||||
const ComponentState v14 = deserializeComponentState(
|
||||
payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0);
|
||||
payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes + kRateTailBytes),
|
||||
48000.0);
|
||||
CHECK(!v14.params.limiterEnabled);
|
||||
CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));
|
||||
CHECK(v14.params.keyTrack == 0.25);
|
||||
|
||||
// And a v13 blob, one rung further back, lifts to BOTH defaults.
|
||||
const ComponentState v13 = deserializeComponentState(
|
||||
payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes),
|
||||
48000.0);
|
||||
payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes), 48000.0);
|
||||
CHECK(!v13.params.limiterEnabled);
|
||||
CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||
CHECK(v13.params.keyTrack == 0.25);
|
||||
@@ -1280,37 +1295,49 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact()
|
||||
|
||||
// The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole
|
||||
// discipline is that each version's fields are a strict suffix on the previous version's, so
|
||||
// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved
|
||||
// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off
|
||||
// their defaults, so transposing the two writes fails on the values and not just the layout.
|
||||
// v14's Hold pair must be emitted BEFORE v15's limiter byte, and both before v16's rate pair,
|
||||
// or every blob already saved at those rungs mis-parses. Asserted at absolute offsets from the
|
||||
// end of the blob, with every field off its default, so transposing any two writes fails on the
|
||||
// values and not just the layout.
|
||||
static void testAppendedTailsSitInVersionOrderOnTheWire() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
|
||||
in.params.limiterEnabled = true;
|
||||
in.params.play.playRate = 2.0; // 0x4000000000000000 LE
|
||||
in.params.play.pitchOffsetSemitones = -12.0; // 0xC028000000000000 LE
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
CHECK(bytes.size() > kTrailingTailBytes);
|
||||
|
||||
// The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte
|
||||
// modifier, then the v15 limiter byte.
|
||||
const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes;
|
||||
// In order: the v14 Hold's 4-byte LE exponent, its 1-byte modifier, the v15 limiter byte,
|
||||
// then the v16 rate and pitch-offset doubles.
|
||||
const std::size_t holdAt = bytes.size() - kTrailingTailBytes;
|
||||
CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement
|
||||
CHECK(bytes[holdAt + 1] == 0xff);
|
||||
CHECK(bytes[holdAt + 2] == 0xff);
|
||||
CHECK(bytes[holdAt + 3] == 0xff);
|
||||
CHECK(bytes[holdAt + 4] == static_cast<std::uint8_t>(note::DivisionModifier::Triplet));
|
||||
CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last
|
||||
CHECK(bytes[holdAt + 5] == 0x01); // the limiter enable
|
||||
const std::size_t rateAt = holdAt + kBakeHoldTailBytes + kLimiterTailBytes;
|
||||
const std::uint8_t wantRate[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40};
|
||||
const std::uint8_t wantOffset[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0xc0};
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
CHECK(bytes[rateAt + i] == wantRate[i]);
|
||||
CHECK(bytes[rateAt + 8 + i] == wantOffset[i]);
|
||||
}
|
||||
|
||||
// The same claim from the other side: flipping only the limiter changes only the LAST
|
||||
// byte, so the byte the limiter owns cannot be one the Hold also writes.
|
||||
// The same claim from the other side: flipping only the limiter changes only the byte the
|
||||
// limiter owns, so it cannot be one the Hold or the rate pair also writes.
|
||||
ComponentState off = in;
|
||||
off.params.limiterEnabled = false;
|
||||
const std::vector<std::uint8_t> offBytes = serializeComponentState(off);
|
||||
CHECK(offBytes.size() == bytes.size());
|
||||
if (offBytes.size() == bytes.size()) {
|
||||
for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]);
|
||||
CHECK(offBytes[bytes.size() - 1] == 0x00);
|
||||
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
||||
if (i == holdAt + 5) CHECK(offBytes[i] == 0x00);
|
||||
else CHECK(offBytes[i] == bytes[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,10 +1435,10 @@ static void testV13BlobLiftsToTheDefaultHold() {
|
||||
in.params.loopCrossfadeFrames = 128;
|
||||
in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);
|
||||
|
||||
// Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what
|
||||
// the v13 binary would have written.
|
||||
const std::vector<std::uint8_t> v13 = payloadDowngradedTo(
|
||||
in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
// Stamp the payload back to v13 and drop every tail appended since: byte-for-byte what the
|
||||
// v13 binary would have written.
|
||||
const std::vector<std::uint8_t> v13 =
|
||||
payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes);
|
||||
const ComponentState out = deserializeComponentState(v13, 48000.0);
|
||||
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||
CHECK(out.selectionId == "pad");
|
||||
@@ -1427,18 +1454,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
CHECK(bytes.size() >= kTrailingTailBytes);
|
||||
bytes.resize(bytes.size() - kTrailingTailBytes);
|
||||
legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
|
||||
legacy::u8v(bytes, 200); // an unnamed modifier byte
|
||||
putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault
|
||||
// Well-formed, off-default tails after it, so the clamp is the only fault in the blob.
|
||||
putLimiterTail(bytes, true);
|
||||
putRateTail(bytes, 0.5, 7.0);
|
||||
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
CHECK(out.params.bakeHold ==
|
||||
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
|
||||
// The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly
|
||||
// the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier.
|
||||
// The tails behind the corrupt pair still land on their own fields: the clamp consumed
|
||||
// exactly the five bytes it was owed, so nothing after it was read out of alignment.
|
||||
CHECK(out.params.limiterEnabled);
|
||||
CHECK(out.params.play.playRate == 0.5);
|
||||
CHECK(out.params.play.pitchOffsetSemitones == 7.0);
|
||||
}
|
||||
|
||||
// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded
|
||||
@@ -1451,22 +1482,98 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
|
||||
in.params.loopCrossfadeFrames = 96;
|
||||
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
|
||||
in.params.limiterEnabled = true;
|
||||
in.params.play.playRate = 0.75;
|
||||
in.params.play.pitchOffsetSemitones = -5.0;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
CHECK(bytes.size() >= kTrailingTailBytes);
|
||||
bytes.resize(bytes.size() - kTrailingTailBytes);
|
||||
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.params.limiterEnabled); // stranded behind the Hold, and revived not wiped
|
||||
// The rate pair is stranded two rungs behind the damage and must reach its own neutral
|
||||
// rather than fabricating one out of the drained bytes.
|
||||
CHECK(out.params.play.playRate == 1.0);
|
||||
CHECK(out.params.play.pitchOffsetSemitones == 0.0);
|
||||
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 rate + pitch-offset tail (payload v16) ------------------------------
|
||||
|
||||
// The rung's whole contract in one test: a v16 blob round-trips BOTH fields exactly, and a v15
|
||||
// blob — a strict prefix of it, byte-for-byte what the shipped binary wrote — lifts to unity
|
||||
// rate and zero offset, which is what every instance before them played. The neighbours ahead of
|
||||
// the pair are checked too, so a misread that shifted the record shows up here rather than as a
|
||||
// silent retune.
|
||||
static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
in.params.keyTrack = 0.75;
|
||||
in.params.limiterEnabled = true;
|
||||
in.params.bakeHold = note::makeDivision(3, note::DivisionModifier::Dotted);
|
||||
// Both off their defaults, and both exactly representable, so == is the right comparison:
|
||||
// the codec stores raw doubles and must not round either one.
|
||||
in.params.play.playRate = 0.75;
|
||||
in.params.play.pitchOffsetSemitones = -7.5;
|
||||
|
||||
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||
CHECK(out.params.play.playRate == 0.75);
|
||||
CHECK(out.params.play.pitchOffsetSemitones == -7.5);
|
||||
CHECK(out.params.limiterEnabled);
|
||||
CHECK(out.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
|
||||
CHECK(out.params.keyTrack == 0.75);
|
||||
|
||||
const ComponentState v15 = deserializeComponentState(
|
||||
payloadDowngradedTo(in, kParamsLimiterVersion, kRateTailBytes), 48000.0);
|
||||
CHECK(v15.params.play.playRate == 1.0);
|
||||
CHECK(v15.params.play.pitchOffsetSemitones == 0.0);
|
||||
// Everything the v15 binary DID write survives the lift untouched.
|
||||
CHECK(v15.params.limiterEnabled);
|
||||
CHECK(v15.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
|
||||
CHECK(v15.params.keyTrack == 0.75);
|
||||
|
||||
// Unity/zero is the default at the struct as well as on the wire, so a fresh instance and a
|
||||
// lifted v15 one are the same sound.
|
||||
CHECK(PlaySeconds{}.playRate == 1.0);
|
||||
CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0);
|
||||
}
|
||||
|
||||
// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate
|
||||
// multiplies a read increment (the engine's own clampStretchRate is the one authority on its
|
||||
// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result
|
||||
// reaches a per-sample cast. Both degrade to their neutral rather than through.
|
||||
static void testCorruptRateOrOffsetDegradesToTheNeutral() {
|
||||
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||||
const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = {
|
||||
{nan, 3.0, 1.0, 3.0},
|
||||
{0.75, nan, 0.75, 0.0},
|
||||
{0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head
|
||||
{-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards
|
||||
{std::numeric_limits<double>::infinity(), 3.0, 1.0, 3.0},
|
||||
{0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw
|
||||
{0.75, -1e9, 0.75, 0.0},
|
||||
{0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range
|
||||
{0.75, -24.0, 0.75, -24.0},
|
||||
};
|
||||
for (const auto& c : cases) {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kRateTailBytes);
|
||||
bytes.resize(bytes.size() - kRateTailBytes);
|
||||
putRateTail(bytes, c.rate, c.offset);
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
CHECK(out.params.play.playRate == c.wantRate);
|
||||
CHECK(out.params.play.pitchOffsetSemitones == c.wantOffset);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -2079,6 +2186,8 @@ int main() {
|
||||
testV13BlobLiftsToTheDefaultHold();
|
||||
testBakeHoldCorruptPairClampsToTheLadder();
|
||||
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
|
||||
testRateAndPitchOffsetRoundTripAndV15LiftsToUnity();
|
||||
testCorruptRateOrOffsetDegradesToTheNeutral();
|
||||
if (failures == 0) {
|
||||
std::printf("component_state_io_tests: all tests passed\n");
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user