Γ-W1-T2: the master bus — a true-peak limiter whose ceiling is a theorem, the meter's published half, and the plugin's first PDC report
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,0x0e,0x00,0x00,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0f,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,
|
||||
@@ -549,6 +549,8 @@ static void testGoldenFullBlobFixture() {
|
||||
// --- payload v14 bake Hold, at its one-bar default ---
|
||||
0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1)
|
||||
0x00, // Straight
|
||||
// --- payload v15 limiter enable ---
|
||||
0x00, // bypassed (the default)
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
@@ -596,19 +598,22 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||
}
|
||||
CHECK(kComponentStateVersion == 11);
|
||||
CHECK(kParamsPayloadVersion == 14);
|
||||
CHECK(kParamsPayloadVersion == 15);
|
||||
CHECK(kParamsSingleRecordVersion == 8);
|
||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||
// 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.
|
||||
// The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter 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
|
||||
// testAppendedTailsSitInVersionOrderOnTheWire's job.
|
||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
||||
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
||||
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
|
||||
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion);
|
||||
CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion);
|
||||
CHECK(kParamsPayloadVersion == kParamsLimiterVersion);
|
||||
}
|
||||
|
||||
// --- The filter tail (payload v9) --------------------------------------------
|
||||
@@ -787,21 +792,28 @@ 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
|
||||
// The three 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;
|
||||
static constexpr std::size_t kLimiterTailBytes = 1;
|
||||
|
||||
// The v14 tail, re-appended after a splice so the record still ends where the reader expects.
|
||||
// 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.
|
||||
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)));
|
||||
legacy::u8v(out, static_cast<std::uint8_t>(modifier));
|
||||
}
|
||||
|
||||
static void putDefaultBakeHoldTail(std::vector<std::uint8_t>& out) {
|
||||
static void putLimiterTail(std::vector<std::uint8_t>& out, bool enabled) {
|
||||
legacy::u8v(out, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
|
||||
@@ -836,8 +848,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);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
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
|
||||
@@ -846,7 +858,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
|
||||
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
|
||||
legacy::u8v(bytes, 0);
|
||||
legacy::u8v(bytes, 0);
|
||||
putDefaultBakeHoldTail(bytes);
|
||||
putDefaultTrailingTails(bytes);
|
||||
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
// Every param preceding AND following the corrupted amp tail survives untouched.
|
||||
@@ -884,8 +896,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
|
||||
in.params.loopCrossfadeFrames = 321;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
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.
|
||||
|
||||
@@ -903,6 +915,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
|
||||
CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded
|
||||
CHECK(!out.params.velocityCurve.points()[0].hard);
|
||||
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||
CHECK(!out.params.limiterEnabled);
|
||||
}
|
||||
|
||||
// The stranding case, and the reason a bogus count DRAINS rather than skipping in place: the
|
||||
@@ -918,6 +931,9 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric
|
||||
in.params.play.adsr.releaseSeconds = 0.44;
|
||||
in.params.loopCrossfadeFrames = 321;
|
||||
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
|
||||
// Enabled on the in-state so the drain has something to cost on the LAST tail too: a drain
|
||||
// that stopped short of it would hand back the stored `true` off bytes it cannot trust.
|
||||
in.params.limiterEnabled = true;
|
||||
|
||||
// A THREE-point amp curve, so its flag block is three bytes rather than two: the
|
||||
// misaligned reads below then land on bytes that decode to something other than the
|
||||
@@ -928,12 +944,13 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric
|
||||
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||
|
||||
// A REAL blob with exactly ONE corrupt field: the amp hard-flag count, patched in place.
|
||||
// Everything after it — the amp flags, both well-formed neighbour blocks, and the Hold —
|
||||
// is exactly what the serializer wrote, which is the whole hazard.
|
||||
// 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;
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kThreePointFlagTail + kBakeHoldTailBytes);
|
||||
const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kBakeHoldTailBytes;
|
||||
CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails);
|
||||
const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails;
|
||||
for (std::size_t i = 0; i < 4; ++i) bytes[ampCountAt + i] = i == 0 ? 0x00 : 0xFF;
|
||||
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
@@ -950,6 +967,8 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric
|
||||
// clamps to the top rung.
|
||||
CHECK(out.params.bakeHold !=
|
||||
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
|
||||
// The drain reaches the last tail as well: the stored `true` is past the damage too.
|
||||
CHECK(!out.params.limiterEnabled);
|
||||
}
|
||||
|
||||
// Numeric domains are established at the DOOR, not at each consumer. A NaN pitch depth reaches
|
||||
@@ -1025,10 +1044,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
|
||||
in.params.loopCrossfadeFrames = 5;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
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);
|
||||
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
// 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);
|
||||
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
|
||||
legacy::u8v(bytes, 0x00);
|
||||
|
||||
@@ -1214,6 +1233,87 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||
}
|
||||
}
|
||||
|
||||
// The v15 rung. The limiter enable is a strict SUFFIX on v14, so a v14 blob is a valid prefix
|
||||
// of it and lifts to BYPASSED — the migration bar for a project saved before the limiter
|
||||
// existed: it reopens with the limiter off and therefore sounding identical.
|
||||
//
|
||||
// The v14 case is also the load-bearing ORDERING proof at the reader. A v14 blob is the
|
||||
// current one with its last byte cut, so if the limiter byte were written AHEAD of the Hold
|
||||
// the cut would take the Hold's modifier instead and the v14 read would resolve the Hold off
|
||||
// the limiter byte — the stored division below would not survive. Transposing the two writes
|
||||
// fails here, not merely in the byte fixture.
|
||||
static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
in.params.limiterEnabled = true;
|
||||
in.params.keyTrack = 0.25; // a neighbour ahead of the new byte, so a misread shows up here too
|
||||
// Ξ's v14 field, off its default, so the lift below can prove it came back untouched.
|
||||
in.params.bakeHold = note::makeDivision(-1, note::DivisionModifier::Dotted);
|
||||
|
||||
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||
CHECK(out.params.limiterEnabled);
|
||||
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
|
||||
// what the Ξ binary wrote. Its Hold must survive in full.
|
||||
const ComponentState v14 = deserializeComponentState(
|
||||
payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 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);
|
||||
CHECK(!v13.params.limiterEnabled);
|
||||
CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||
CHECK(v13.params.keyTrack == 0.25);
|
||||
|
||||
// Bypassed is the default at the struct as well as on the wire.
|
||||
CHECK(!InstrumentParams{}.limiterEnabled);
|
||||
const ComponentState fresh =
|
||||
deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
|
||||
CHECK(!fresh.params.limiterEnabled);
|
||||
}
|
||||
|
||||
// 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.
|
||||
static void testAppendedTailsSitInVersionOrderOnTheWire() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
|
||||
in.params.limiterEnabled = true;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
|
||||
// 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;
|
||||
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
|
||||
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift
|
||||
// is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in
|
||||
// [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside
|
||||
@@ -1308,10 +1408,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 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);
|
||||
// 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);
|
||||
const ComponentState out = deserializeComponentState(v13, 48000.0);
|
||||
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
|
||||
CHECK(out.selectionId == "pad");
|
||||
@@ -1327,18 +1427,22 @@ 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);
|
||||
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
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
|
||||
|
||||
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.
|
||||
CHECK(out.params.limiterEnabled);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded
|
||||
// behind it, the limiter's revive is what stops that truncation resetting the record anyway.
|
||||
static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
|
||||
ComponentState in;
|
||||
in.selectionId = "pad";
|
||||
@@ -1346,15 +1450,17 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
|
||||
in.params.play.adsr.attackSeconds = 0.017;
|
||||
in.params.loopCrossfadeFrames = 96;
|
||||
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
|
||||
in.params.limiterEnabled = true;
|
||||
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() >= kBakeHoldTailBytes);
|
||||
bytes.resize(bytes.size() - kBakeHoldTailBytes);
|
||||
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
|
||||
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
|
||||
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
|
||||
CHECK(out.selectionId == "pad");
|
||||
CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
|
||||
CHECK(out.params.play.adsr.attackSeconds == 0.017);
|
||||
@@ -1935,6 +2041,8 @@ int main() {
|
||||
testLoopSpanAndCrossfadeRoundTrip();
|
||||
testNegativeCrossfadeOnTheWireLiftsToZero();
|
||||
testPriorPayloadVersionsLiftToAHardSeam();
|
||||
testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact();
|
||||
testAppendedTailsSitInVersionOrderOnTheWire();
|
||||
testPreV12FilterVelocityLiftsAsAPureDomainReTag();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Standalone tests for reasampler::instrument::engine::Limiter — no VST3, no REAPER, no
|
||||
// framework. The properties the master bus depends on, asserted rather than judged by ear:
|
||||
//
|
||||
// * bypassed and settled, process() does not touch one byte of the buffers (the byte-identical
|
||||
// at-rest path) and reports no reduction;
|
||||
// * engaged below the ceiling, the output is the input DELAYED and bit-exact — nothing is
|
||||
// louder, quieter or altered at rest, and there is no makeup gain to find;
|
||||
// * engaged on program +12 dB over, no output sample passes the ceiling; bypassed, the same
|
||||
// program still passes 0 dBFS, so the toggle is doing the work;
|
||||
// * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose
|
||||
// inter-sample peak does not still engages;
|
||||
// * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle;
|
||||
// * the engage/disengage crossfade leaves no step larger than the signal's own.
|
||||
|
||||
#include "../src/core/instrument/engine/limiter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::instrument::engine;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static constexpr double kRate = 48000.0;
|
||||
|
||||
// A deterministic non-repeating pattern, so an untouched-buffer check cannot pass by accident.
|
||||
static std::vector<float> pattern(int n, float scale = 1.f) {
|
||||
std::vector<float> v(static_cast<std::size_t>(n));
|
||||
std::uint32_t s = 0x1234567u;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
s = s * 1664525u + 1013904223u;
|
||||
v[static_cast<std::size_t>(i)] =
|
||||
scale * (static_cast<float>(static_cast<int>(s >> 8) % 20001 - 10000) / 10000.f);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Runs `in` through `lim` in blocks of `block`, returning the output and the smallest gain
|
||||
// reported across the whole run.
|
||||
static std::vector<float> runMono(Limiter& lim, const std::vector<float>& in, int block,
|
||||
float* minGainOut = nullptr) {
|
||||
std::vector<float> out = in;
|
||||
float lowest = 1.f;
|
||||
for (std::size_t i = 0; i < out.size(); i += static_cast<std::size_t>(block)) {
|
||||
const int n = static_cast<int>(
|
||||
std::min(static_cast<std::size_t>(block), out.size() - i));
|
||||
const float g = lim.process(out.data() + i, nullptr, n);
|
||||
if (g < lowest) lowest = g;
|
||||
}
|
||||
if (minGainOut) *minGainOut = lowest;
|
||||
return out;
|
||||
}
|
||||
|
||||
static void testBypassedLeavesEveryByteUntouched() {
|
||||
Limiter lim;
|
||||
lim.prepare(kRate);
|
||||
CHECK(!lim.enabled());
|
||||
const std::vector<float> in = pattern(2048, 1.8f); // well over full scale
|
||||
float minGain = 0.f;
|
||||
const std::vector<float> out = runMono(lim, in, 512, &minGain);
|
||||
bool identical = true;
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
if (out[i] != in[i]) { identical = false; break; }
|
||||
}
|
||||
CHECK(identical);
|
||||
CHECK(minGain == 1.f);
|
||||
// And that untouched signal still passes 0 dBFS — the toggle, not the meter, is what
|
||||
// stops it.
|
||||
float peak = 0.f;
|
||||
for (float v : out) peak = std::max(peak, std::fabs(v));
|
||||
CHECK(peak > 1.f);
|
||||
}
|
||||
|
||||
static void testEngagedBelowThresholdIsTheInputDelayedBitExactly() {
|
||||
Limiter lim;
|
||||
lim.setEnabled(true);
|
||||
lim.prepare(kRate); // prepare snaps to the target: no crossfade, no priming
|
||||
const int latency = limiterLookaheadSamples(kRate);
|
||||
// Comfortably under the ceiling at every sample AND between samples.
|
||||
const std::vector<float> in = pattern(4096, 0.4f);
|
||||
float minGain = 0.f;
|
||||
const std::vector<float> out = runMono(lim, in, 256, &minGain);
|
||||
CHECK(minGain == 1.f); // exactly unity: there is no makeup gain and no residual trim
|
||||
bool exact = true;
|
||||
for (std::size_t i = static_cast<std::size_t>(latency); i < in.size(); ++i) {
|
||||
if (out[i] != in[i - static_cast<std::size_t>(latency)]) { exact = false; break; }
|
||||
}
|
||||
CHECK(exact);
|
||||
}
|
||||
|
||||
static void testEngagedHoldsTheCeilingOnProgramTwelveDbOver() {
|
||||
Limiter lim;
|
||||
lim.setEnabled(true);
|
||||
lim.prepare(kRate);
|
||||
const int latency = limiterLookaheadSamples(kRate);
|
||||
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
||||
// +12 dB over the ceiling, sustained, with the transient content the pattern gives.
|
||||
std::vector<float> in = pattern(24000, ceiling * 3.98f);
|
||||
float minGain = 0.f;
|
||||
const std::vector<float> out = runMono(lim, in, 128, &minGain);
|
||||
CHECK(minGain < 0.4f); // it really did pull the gain down
|
||||
float worst = 0.f;
|
||||
for (std::size_t i = static_cast<std::size_t>(latency); i < out.size(); ++i) {
|
||||
worst = std::max(worst, std::fabs(out[i]));
|
||||
}
|
||||
// Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding.
|
||||
CHECK(worst <= ceiling * 1.0001f);
|
||||
}
|
||||
|
||||
static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() {
|
||||
// fs/4 at 45 degrees: every SAMPLE sits at A/sqrt(2) while the waveform reaches A between
|
||||
// them. A sample-peak detector would pass this through untouched.
|
||||
const double amp = 1.2;
|
||||
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
||||
std::vector<float> in(8000);
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
in[i] = static_cast<float>(
|
||||
amp * std::cos(3.14159265358979323846 * (0.5 * static_cast<double>(i) + 0.25)));
|
||||
}
|
||||
float samplePeak = 0.f;
|
||||
for (float v : in) samplePeak = std::max(samplePeak, std::fabs(v));
|
||||
CHECK(samplePeak < ceiling); // the premise: no SAMPLE is over
|
||||
|
||||
Limiter lim;
|
||||
lim.setEnabled(true);
|
||||
lim.prepare(kRate);
|
||||
float minGain = 0.f;
|
||||
runMono(lim, in, 256, &minGain);
|
||||
CHECK(minGain < 1.f);
|
||||
}
|
||||
|
||||
static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() {
|
||||
Limiter lim;
|
||||
lim.prepare(kRate);
|
||||
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
||||
const std::vector<float> src = pattern(48000, ceiling * 2.5f);
|
||||
std::vector<float> l = src, r = src; // dual mono: L and R are the same signal
|
||||
const int block = 64;
|
||||
bool centered = true;
|
||||
for (std::size_t i = 0; i < l.size(); i += static_cast<std::size_t>(block)) {
|
||||
// Toggle on a quarter in and off three quarters in, so the run covers bypassed,
|
||||
// the engage crossfade, fully engaged, the disengage crossfade, and bypassed again.
|
||||
if (i >= l.size() / 4 && !lim.enabled()) lim.setEnabled(true);
|
||||
if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false);
|
||||
const int n = static_cast<int>(
|
||||
std::min(static_cast<std::size_t>(block), l.size() - i));
|
||||
lim.process(l.data() + i, r.data() + i, n);
|
||||
}
|
||||
for (std::size_t i = 0; i < l.size(); ++i) {
|
||||
if (l[i] != r[i]) { centered = false; break; }
|
||||
}
|
||||
CHECK(centered);
|
||||
// And the engaged stretch really was limited, so the equality above is not equality on an
|
||||
// untouched buffer.
|
||||
float worstEngaged = 0.f;
|
||||
for (std::size_t i = l.size() / 2; i < (l.size() * 3) / 4; ++i) {
|
||||
worstEngaged = std::max(worstEngaged, std::fabs(l[i]));
|
||||
}
|
||||
CHECK(worstEngaged <= ceiling * 1.0001f);
|
||||
CHECK(worstEngaged > 0.f);
|
||||
}
|
||||
|
||||
static void testToggleEmitsNoStepLargerThanTheSignalsOwn() {
|
||||
// A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead,
|
||||
// which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step
|
||||
// by up to twice the amplitude, so this assertion has real teeth.
|
||||
const double freq = 440.0;
|
||||
const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting
|
||||
std::vector<float> x(48000);
|
||||
for (std::size_t i = 0; i < x.size(); ++i) {
|
||||
x[i] = static_cast<float>(
|
||||
amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast<double>(i) / kRate));
|
||||
}
|
||||
const float naturalStep =
|
||||
static_cast<float>(amp * 2.0 * 3.14159265358979323846 * freq / kRate);
|
||||
|
||||
Limiter lim;
|
||||
lim.prepare(kRate);
|
||||
const int block = 32;
|
||||
for (std::size_t i = 0; i < x.size(); i += static_cast<std::size_t>(block)) {
|
||||
if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true);
|
||||
if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false);
|
||||
const int n = static_cast<int>(
|
||||
std::min(static_cast<std::size_t>(block), x.size() - i));
|
||||
lim.process(x.data() + i, nullptr, n);
|
||||
}
|
||||
float worstStep = 0.f;
|
||||
for (std::size_t i = 1; i < x.size(); ++i) {
|
||||
worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1]));
|
||||
}
|
||||
CHECK(worstStep <= naturalStep * 1.2f);
|
||||
}
|
||||
|
||||
static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() {
|
||||
Limiter lim;
|
||||
lim.prepare(kRate);
|
||||
const int latency = limiterLookaheadSamples(kRate);
|
||||
const int settle = static_cast<int>(kLimiterCrossfadeSeconds * kRate) + latency + 64;
|
||||
const std::vector<float> src = pattern(4 * settle, 0.3f); // under the ceiling throughout
|
||||
|
||||
std::vector<float> y = src;
|
||||
lim.setEnabled(true);
|
||||
lim.process(y.data(), nullptr, static_cast<int>(y.size()));
|
||||
// Past the crossfade the engaged path is exactly the delayed input again.
|
||||
bool exact = true;
|
||||
for (std::size_t i = static_cast<std::size_t>(settle); i < y.size(); ++i) {
|
||||
if (y[i] != src[i - static_cast<std::size_t>(latency)]) { exact = false; break; }
|
||||
}
|
||||
CHECK(exact);
|
||||
|
||||
std::vector<float> z = src;
|
||||
lim.setEnabled(false);
|
||||
lim.process(z.data(), nullptr, static_cast<int>(z.size()));
|
||||
bool passthrough = true;
|
||||
for (std::size_t i = static_cast<std::size_t>(settle); i < z.size(); ++i) {
|
||||
if (z[i] != src[i]) { passthrough = false; break; }
|
||||
}
|
||||
CHECK(passthrough);
|
||||
// And once settled bypassed, the next block is untouched again.
|
||||
std::vector<float> w = pattern(512, 1.5f);
|
||||
const std::vector<float> before = w;
|
||||
CHECK(lim.process(w.data(), nullptr, static_cast<int>(w.size())) == 1.f);
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < w.size(); ++i) {
|
||||
if (w[i] != before[i]) { untouched = false; break; }
|
||||
}
|
||||
CHECK(untouched);
|
||||
}
|
||||
|
||||
static void testGainNeverRisesAboveUnity() {
|
||||
// "No makeup gain, ever, of any kind" as a property rather than an absence: across quiet,
|
||||
// loud and silent material the applied gain is never above 1 and the output magnitude is
|
||||
// never above the input's own.
|
||||
Limiter lim;
|
||||
lim.setEnabled(true);
|
||||
lim.prepare(kRate);
|
||||
std::vector<float> in = pattern(16000, 2.0f);
|
||||
for (std::size_t i = 4000; i < 8000; ++i) in[i] = 0.f; // a silent stretch
|
||||
for (std::size_t i = 8000; i < 12000; ++i) in[i] *= 0.001f; // and a very quiet one
|
||||
float minGain = 0.f;
|
||||
const std::vector<float> out = runMono(lim, in, 200, &minGain);
|
||||
CHECK(minGain <= 1.f);
|
||||
float inPeak = 0.f, outPeak = 0.f;
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
inPeak = std::max(inPeak, std::fabs(in[i]));
|
||||
outPeak = std::max(outPeak, std::fabs(out[i]));
|
||||
}
|
||||
CHECK(outPeak <= inPeak);
|
||||
}
|
||||
|
||||
static void testBakedConstants() {
|
||||
CHECK(kLimiterCeilingDbTp == -0.3);
|
||||
CHECK(std::fabs(limiterCeilingLinear() - std::pow(10.0, -0.3 / 20.0)) < 1e-12);
|
||||
CHECK(limiterCeilingLinear() < 1.0);
|
||||
// 2 ms at the common rates, and never below the detector's own group delay.
|
||||
CHECK(limiterLookaheadSamples(48000.0) == 96);
|
||||
CHECK(limiterLookaheadSamples(44100.0) == 88);
|
||||
CHECK(limiterLookaheadSamples(96000.0) == 192);
|
||||
CHECK(limiterLookaheadSamples(0.0) == 0);
|
||||
CHECK(limiterLookaheadSamples(-1.0) == 0);
|
||||
CHECK(limiterLookaheadSamples(100.0) > kLimiterOsDelay);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testBypassedLeavesEveryByteUntouched();
|
||||
testEngagedBelowThresholdIsTheInputDelayedBitExactly();
|
||||
testEngagedHoldsTheCeilingOnProgramTwelveDbOver();
|
||||
testTruePeakDetectionEngagesWhereSamplePeakWouldNot();
|
||||
testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle();
|
||||
testToggleEmitsNoStepLargerThanTheSignalsOwn();
|
||||
testCrossfadeSettlesToTheExactEngagedAndBypassedPaths();
|
||||
testGainNeverRisesAboveUnity();
|
||||
testBakedConstants();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("limiter tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Standalone tests for reasampler::instrument::engine::meter_ballistics — no VST3, no REAPER,
|
||||
// no framework. The meter's whole behaviour is asserted here without a host: instantaneous
|
||||
// rise, the 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip
|
||||
// latch and its clear, and the dB -> normalized map the bar is drawn against.
|
||||
|
||||
#include "../src/core/instrument/engine/meter_ballistics.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::instrument::engine;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
|
||||
|
||||
static double linearFromDb(double db) { return std::pow(10.0, db / 20.0); }
|
||||
|
||||
static void testDbFromLinear() {
|
||||
CHECK(near(meterDbFromLinear(1.0), 0.0));
|
||||
CHECK(near(meterDbFromLinear(0.5), -6.0205999132796239, 1e-9));
|
||||
CHECK(near(meterDbFromLinear(2.0), 6.0205999132796239, 1e-9));
|
||||
// Silence and anything under the floor read the floor, not -inf: the ballistics subtract
|
||||
// from this value, so it has to stay finite.
|
||||
CHECK(meterDbFromLinear(0.0) == kMeterFloorDb);
|
||||
CHECK(meterDbFromLinear(-1.0) == kMeterFloorDb);
|
||||
CHECK(meterDbFromLinear(1e-9) == kMeterFloorDb);
|
||||
}
|
||||
|
||||
static void testNormFromDbIsLinearInDbAndClamped() {
|
||||
CHECK(meterNormFromDb(kMeterFloorDb) == 0.0);
|
||||
CHECK(meterNormFromDb(kMeterTopDb) == 1.0);
|
||||
CHECK(meterNormFromDb(-1000.0) == 0.0);
|
||||
CHECK(meterNormFromDb(1000.0) == 1.0);
|
||||
// Linear in dB: equal dB steps are equal normalized steps anywhere in the span.
|
||||
const double a = meterNormFromDb(-48.0) - meterNormFromDb(-54.0);
|
||||
const double b = meterNormFromDb(-6.0) - meterNormFromDb(-12.0);
|
||||
CHECK(near(a, b, 1e-12));
|
||||
CHECK(near(a, 6.0 / (kMeterTopDb - kMeterFloorDb), 1e-12));
|
||||
// The 0 dB tick, which the scale's heavier rule is drawn on.
|
||||
CHECK(near(meterNormFromDb(0.0), 60.0 / 66.0, 1e-12));
|
||||
}
|
||||
|
||||
static void testRiseIsInstantaneous() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, linearFromDb(-12.0), 1.0 / 30.0);
|
||||
CHECK(near(s.levelDb, -12.0, 1e-9));
|
||||
// A louder block on the very next frame displays at once, however short the frame.
|
||||
s = advanceMeter(s, linearFromDb(-3.0), 1e-6);
|
||||
CHECK(near(s.levelDb, -3.0, 1e-9));
|
||||
}
|
||||
|
||||
static void testFallIsTwentyDbPerSecond() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, 1.0, 0.0); // 0 dBFS
|
||||
CHECK(near(s.levelDb, 0.0, 1e-9));
|
||||
s = advanceMeter(s, 0.0, 0.5);
|
||||
CHECK(near(s.levelDb, -10.0, 1e-9));
|
||||
s = advanceMeter(s, 0.0, 0.25);
|
||||
CHECK(near(s.levelDb, -15.0, 1e-9));
|
||||
// The fall never takes the bar under the peak the block itself carried.
|
||||
s = advanceMeter(s, linearFromDb(-14.0), 1.0);
|
||||
CHECK(near(s.levelDb, -14.0, 1e-9));
|
||||
// And it stops at the floor.
|
||||
for (int i = 0; i < 20; ++i) s = advanceMeter(s, 0.0, 0.5);
|
||||
CHECK(near(s.levelDb, kMeterFloorDb, 1e-9));
|
||||
}
|
||||
|
||||
static void testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, 1.0, 0.0);
|
||||
CHECK(near(s.holdDb, 0.0, 1e-9));
|
||||
CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12));
|
||||
|
||||
// 1.4 s of silence: the bar has long fallen away, the tick has not moved.
|
||||
for (int i = 0; i < 14; ++i) s = advanceMeter(s, 0.0, 0.1);
|
||||
CHECK(near(s.holdDb, 0.0, 1e-9));
|
||||
CHECK(s.levelDb < -20.0);
|
||||
|
||||
// Past 1.5 s it releases at the bar's own rate — 0.2 s past the latch is 4 dB down.
|
||||
s = advanceMeter(s, 0.0, 0.3);
|
||||
CHECK(near(s.holdDb, -4.0, 1e-9));
|
||||
s = advanceMeter(s, 0.0, 0.1);
|
||||
CHECK(near(s.holdDb, -6.0, 1e-9));
|
||||
|
||||
// A new peak re-latches it and restarts the hold.
|
||||
s = advanceMeter(s, linearFromDb(-2.0), 0.1);
|
||||
CHECK(near(s.holdDb, -2.0, 1e-9));
|
||||
CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12));
|
||||
}
|
||||
|
||||
static void testHoldNeverFallsBelowTheBar() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, 1.0, 0.0);
|
||||
for (int i = 0; i < 40; ++i) s = advanceMeter(s, linearFromDb(-20.0), 0.1);
|
||||
CHECK(near(s.levelDb, -20.0, 1e-9));
|
||||
CHECK(near(s.holdDb, -20.0, 1e-9));
|
||||
}
|
||||
|
||||
static void testClipLatchesAtFullScaleAndOnlyClearsOnRequest() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, linearFromDb(-0.01), 0.1);
|
||||
CHECK(!s.clip); // under 0 dBFS does not latch
|
||||
s = advanceMeter(s, 1.0, 0.1);
|
||||
CHECK(s.clip); // exactly 0 dBFS does
|
||||
for (int i = 0; i < 100; ++i) s = advanceMeter(s, 0.0, 0.1);
|
||||
CHECK(s.clip); // and silence does not unlatch it
|
||||
s = clearMeterClip(s);
|
||||
CHECK(!s.clip);
|
||||
s = advanceMeter(s, linearFromDb(-6.0), 0.1);
|
||||
CHECK(!s.clip); // cleared stays cleared while nothing reaches full scale
|
||||
CHECK(near(s.levelDb, -6.0, 1e-9)); // and clearing left the ballistics alone
|
||||
}
|
||||
|
||||
static void testNonPositiveElapsedFreezesTheBallistics() {
|
||||
MeterState s;
|
||||
s = advanceMeter(s, 1.0, 0.0);
|
||||
const MeterState frozen = advanceMeter(s, 0.0, -1.0);
|
||||
CHECK(near(frozen.levelDb, s.levelDb, 1e-12));
|
||||
CHECK(near(frozen.holdDb, s.holdDb, 1e-12));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDbFromLinear();
|
||||
testNormFromDbIsLinearInDbAndClamped();
|
||||
testRiseIsInstantaneous();
|
||||
testFallIsTwentyDbPerSecond();
|
||||
testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate();
|
||||
testHoldNeverFallsBelowTheBar();
|
||||
testClipLatchesAtFullScaleAndOnlyClearsOnRequest();
|
||||
testNonPositiveElapsedFreezesTheBallistics();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("meter_ballistics tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user