note: close every value type's domain at construction, so resolveNote is finite for every constructible input

Division and OffsetAmount get single normalizing doors and private constructors; fromBpm validates by running the conversions rather than their reciprocal. Readers drop their re-clamps and default labels.
This commit is contained in:
2026-07-30 20:37:12 -04:00
parent d923b352ae
commit a80eb76c1f
11 changed files with 398 additions and 119 deletions
+49 -19
View File
@@ -3,7 +3,8 @@
//
// Covers: the beat length of all 39 divisions against a literal rung table (NOT the module's
// own exponent formula); the 1/64 and 64/1 extremes; the four named example divisions; the
// label notation; picker order and index round-trip; off-ladder clamping.
// label notation; picker order and index round-trip; off-ladder clamping of BOTH persisted
// fields, measured through the readers rather than by comparing two clamped values.
#include "../src/core/instrument/note/musical_division.h"
@@ -76,9 +77,14 @@ static void testExtremes() {
0.0625));
CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Straight)),
256.0));
// The dotted 64/1 is the single longest programmable note.
// The dotted 64/1 is the single longest programmable note, and kMaxDivisionBeats — which
// note_program checks the tempo conversions' domain against — must name exactly it.
CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted)),
384.0));
CHECK(almostEqual(kMaxDivisionBeats, 384.0));
for (int i = 0; i < kDivisionCount; ++i) {
CHECK(divisionBeats(divisionAt(i)) <= kMaxDivisionBeats);
}
// The 1/64 triplet is the shortest.
CHECK(almostEqual(divisionBeats(makeDivision(kMinQuarterExponent, DivisionModifier::Triplet)),
0.0625 * 2.0 / 3.0));
@@ -144,24 +150,47 @@ static void testEverySetMemberIsDistinct() {
// --- Clamping -----------------------------------------------------------------
static void testOffLadderExponentClampsToTheNearestRung() {
CHECK(makeDivision(-99, DivisionModifier::Straight)
== makeDivision(kMinQuarterExponent, DivisionModifier::Straight));
CHECK(makeDivision(99, DivisionModifier::Triplet)
== makeDivision(kMaxQuarterExponent, DivisionModifier::Triplet));
// A record carrying an off-ladder exponent still resolves to a real length.
Division corrupt;
corrupt.quarterExponent = 120;
CHECK(almostEqual(divisionBeats(corrupt), 256.0));
// Asserted through the readers, never by comparing two clamped Divisions: a clamp that
// collapsed every exponent to one rung would make Division-to-Division comparisons agree
// with their own mistake.
CHECK(almostEqual(divisionBeats(makeDivision(-99, DivisionModifier::Straight)), 0.0625));
CHECK(divisionLabel(makeDivision(-99, DivisionModifier::Straight)) == "1/64");
CHECK(divisionIndex(makeDivision(-99, DivisionModifier::Straight)) == 0);
CHECK(almostEqual(divisionBeats(makeDivision(99, DivisionModifier::Triplet)),
256.0 * 2.0 / 3.0));
CHECK(divisionLabel(makeDivision(99, DivisionModifier::Triplet)) == "64/1t");
CHECK(divisionIndex(makeDivision(99, DivisionModifier::Triplet)) == kDivisionCount - 1);
// The exponent that only a corrupt persisted record could carry still names a real rung.
CHECK(almostEqual(divisionBeats(makeDivision(120, DivisionModifier::Straight)), 256.0));
}
static void testEqualityNormalizesOffLadderExponentsLikeEveryOtherReader() {
// divisionBeats/divisionIndex/divisionLabel all re-clamp through makeDivision; equality
// must too, or a corrupt persisted value reads as a spurious diff on every reload.
Division corrupt;
corrupt.quarterExponent = 120;
corrupt.modifier = DivisionModifier::Straight;
CHECK(corrupt == makeDivision(kMaxQuarterExponent, DivisionModifier::Straight));
CHECK(corrupt != makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted));
static void testUnnamedModifierClampsToStraight() {
// The other half of the persisted pair. Neither divisionBeats nor divisionLabel can see
// an unnamed modifier — both already fall through to the straight case — so the clamp is
// measured where it does show: the picker index and equality.
const DivisionModifier junk = static_cast<DivisionModifier>(7);
CHECK(divisionIndex(makeDivision(0, junk))
== divisionIndex(makeDivision(0, DivisionModifier::Straight)));
CHECK(divisionLabel(makeDivision(0, junk)) == "1/4"); // and no junk reaches the readout
CHECK(makeDivision(0, junk) == makeDivision(0, DivisionModifier::Straight));
CHECK(makeDivision(0, junk) != makeDivision(0, DivisionModifier::Dotted));
}
static void testEveryConstructibleDivisionIndexesIntoThePickerSet() {
// divisionIndex is what a picker array is subscripted with, so an out-of-set index is an
// overrun in the caller. Both corrupt fields at once is the worst case: 12*3+7 without a
// modifier clamp.
const int exponents[] = {-9000, -99, kMinQuarterExponent, 0, kMaxQuarterExponent, 120, 9000};
for (int e : exponents) {
for (int m = 0; m < 260; ++m) {
const Division d = makeDivision(e, static_cast<DivisionModifier>(m));
const int index = divisionIndex(d);
CHECK(index >= 0 && index < kDivisionCount);
CHECK(divisionAt(index) == d); // and the picker round-trips it back
}
}
}
static void testOutOfRangeIndexClampsIntoTheSet() {
@@ -182,7 +211,8 @@ int main() {
testEverySetMemberIsDistinct();
testOffLadderExponentClampsToTheNearestRung();
testEqualityNormalizesOffLadderExponentsLikeEveryOtherReader();
testUnnamedModifierClampsToStraight();
testEveryConstructibleDivisionIndexesIntoThePickerSet();
testOutOfRangeIndexClampsIntoTheSet();
if (g_fail == 0) std::printf("musical_division: all tests passed\n");
+140 -25
View File
@@ -1,16 +1,20 @@
// Standalone tests for reasampler::instrument::note::note_program — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: velocity clamping; the ms/beats denomination seam and its round-trip, including a
// corrupt denomination byte; anchoring (start to note-on, end to note-off); the resolved
// window against hand-computed values and its windowCollapsed flag; every division resolving
// to its duration in seconds; proportionality across two tempos; record equality and copy
// round-trip; editing an offset via its non-stored view (withMsView/withBeatsView).
// Covers: velocity clamping; the ms/beats denomination seam and its round-trip; anchoring
// (start to note-on, end to note-off); the resolved window against hand-computed values and
// its windowCollapsed flag, including the zero-length window the flag exists to distinguish;
// every division resolving to its duration in seconds; proportionality across two tempos;
// record equality and copy round-trip; editing an offset via its non-stored view
// (withMsView/withBeatsView); what `offsetOf` does to a corrupt magnitude or denomination,
// asserted through EVERY function that branches on one; and the module's headline claim —
// that resolveNote returns finite times for every constructible input.
#include "../src/core/instrument/note/note_program.h"
#include <cmath>
#include <cstdio>
#include <limits>
using namespace reasampler::instrument::note;
@@ -101,9 +105,9 @@ static void testRedenominationRoundTripsLosslessly() {
const OffsetAmount original = offsetFromMs(ms);
const OffsetAmount there = redenominate(original, Denomination::Beats, t);
const OffsetAmount back = redenominate(there, Denomination::Milliseconds, t);
CHECK(there.denomination == Denomination::Beats);
CHECK(back.denomination == Denomination::Milliseconds);
CHECK(almostEqual(back.magnitude, ms, 1e-9 + 1e-9 * std::fabs(ms)));
CHECK(there.denomination() == Denomination::Beats);
CHECK(back.denomination() == Denomination::Milliseconds);
CHECK(almostEqual(back.magnitude(), ms, 1e-9 + 1e-9 * std::fabs(ms)));
// Re-denominating never moves the instant it names.
CHECK(almostEqual(offsetSeconds(there, t), offsetSeconds(original, t)));
}
@@ -112,7 +116,7 @@ static void testRedenominationRoundTripsLosslessly() {
const OffsetAmount back =
redenominate(redenominate(original, Denomination::Milliseconds, t),
Denomination::Beats, t);
CHECK(almostEqual(back.magnitude, beats, 1e-9 + 1e-9 * std::fabs(beats)));
CHECK(almostEqual(back.magnitude(), beats, 1e-9 + 1e-9 * std::fabs(beats)));
}
}
}
@@ -327,19 +331,71 @@ static void testDefaultRecordIsAQuarterNoteWithNoOffsets() {
CHECK(r.velocity == 100); // NoteProgram{}'s default Velocity, documented in note_program.h
}
// --- Corrupt denomination byte --------------------------------------------------
// --- The door: what a corrupt persisted field becomes ----------------------------
static void testOutOfRangeDenominationReadsAsMillisecondsEverywhere() {
// A denomination byte outside {Milliseconds, Beats} is well-defined but unnamed; all
// three readers must default it to the same interpretation or a popup and a bake can
// report different instants for one record.
OffsetAmount corrupt;
corrupt.magnitude = 250.0;
corrupt.denomination = static_cast<Denomination>(7);
static void testUnnamedDenominationBecomesMilliseconds() {
// A denomination byte outside {Milliseconds, Beats} is well-defined but unnamed. The
// door pins it, so it is not merely that the readers agree — the value they read from
// is already Milliseconds by the time any of them sees it.
const OffsetAmount corrupt = offsetOf(250.0, static_cast<Denomination>(7));
CHECK(corrupt.denomination() == Denomination::Milliseconds);
const Tempo t = at(120.0);
CHECK(almostEqual(offsetMs(corrupt, t), 250.0));
CHECK(almostEqual(offsetSeconds(corrupt, t), 0.25));
CHECK(almostEqual(offsetBeats(corrupt, t), t.msToBeats(250.0)));
CHECK(almostEqual(offsetBeats(corrupt, t), 0.5));
}
static void testEveryDenominationBranchingFunctionAgreesWithThePin() {
// The pin is worth nothing if one branching function disagrees with it: an editor that
// flipped a corrupt record to beats would silently change whether it follows the tempo,
// and an equality that saw the raw byte would report a diff on every reload. All six.
const Tempo t = at(120.0); // one beat is 500 ms
const OffsetAmount corrupt = offsetOf(250.0, static_cast<Denomination>(7));
const OffsetAmount asMs = offsetFromMs(250.0);
CHECK(corrupt == asMs); // offsetMs / offsetBeats / offsetSeconds covered above
CHECK(!(corrupt != asMs));
CHECK(redenominate(corrupt, Denomination::Milliseconds, t) == corrupt);
CHECK(redenominate(corrupt, Denomination::Beats, t).denomination() == Denomination::Beats);
CHECK(withMsView(corrupt, 40.0, t) == withMsView(asMs, 40.0, t));
CHECK(withMsView(corrupt, 40.0, t).denomination() == Denomination::Milliseconds);
CHECK(withBeatsView(corrupt, 1.0, t) == withBeatsView(asMs, 1.0, t));
CHECK(withBeatsView(corrupt, 1.0, t).denomination() == Denomination::Milliseconds);
CHECK(almostEqual(withBeatsView(corrupt, 1.0, t).magnitude(), 500.0, 1e-6));
// An unnamed TARGET denomination pins the same way an unnamed stored one does.
CHECK(redenominate(offsetFromBeats(1.0), static_cast<Denomination>(7), t)
== offsetFromMs(500.0));
}
static void testCorruptMagnitudeIsBoundedAtTheDoor() {
const double inf = std::numeric_limits<double>::infinity();
const double nan = std::numeric_limits<double>::quiet_NaN();
// NaN names no value to clamp toward, so it takes the field's own default; an infinity
// does have a nearest representable magnitude, so it clamps like any other overshoot.
CHECK(almostEqual(offsetFromMs(nan).magnitude(), 0.0));
CHECK(almostEqual(offsetFromBeats(nan).magnitude(), 0.0));
CHECK(almostEqual(offsetFromMs(inf).magnitude(), kMaxConvertibleMagnitude));
CHECK(almostEqual(offsetFromBeats(-inf).magnitude(), -kMaxConvertibleMagnitude));
CHECK(almostEqual(offsetFromMs(1e300).magnitude(), kMaxConvertibleMagnitude));
// A NaN offset is a value, not a hole: it equals itself, so it is not a spurious diff.
CHECK(offsetFromMs(nan) == offsetFromMs(0.0));
// Anything inside the domain passes through untouched.
CHECK(almostEqual(offsetFromMs(-12345.678).magnitude(), -12345.678));
}
static void testANanMagnitudeCannotReachTheResolvedWindow() {
// The witness the door exists for: at an unremarkable tempo, a NaN magnitude used to
// make captureStart, rawEnd and captureEnd all NaN, and windowCollapsed read false.
const Tempo t = at(120.0);
const double nan = std::numeric_limits<double>::quiet_NaN();
const ResolvedNote r = resolveNote(
program(makeDivision(0, DivisionModifier::Straight), offsetOf(nan, Denomination::Beats),
offsetOf(nan, Denomination::Milliseconds), 100),
t);
CHECK(almostEqual(r.captureStartSeconds, 0.0));
CHECK(almostEqual(r.captureEndSeconds, 0.5));
CHECK(!r.windowCollapsed);
}
// --- windowCollapsed -------------------------------------------------------------
@@ -359,18 +415,71 @@ static void testWindowCollapsedFlagsAnInvertedWindow() {
CHECK(!normal.windowCollapsed);
}
static void testWindowCollapsedIsFalseForAGenuinelyZeroLengthWindow() {
// The discrimination the flag exists for. A 1/4 at 120 BPM is 500 ms, so an end offset
// of -500 ms puts the raw end EXACTLY on the start: zero-length, but programmed that way
// rather than collapsed, and a popup must be able to tell the two apart.
const Tempo t = at(120.0);
const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight),
offsetFromMs(0.0), offsetFromMs(-500.0), 100),
t);
CHECK(almostEqual(r.captureLengthSeconds(), 0.0));
CHECK(!r.windowCollapsed);
// One millisecond further in is the same zero length, but collapsed.
const ResolvedNote collapsed = resolveNote(
program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
offsetFromMs(-501.0), 100),
t);
CHECK(almostEqual(collapsed.captureLengthSeconds(), 0.0));
CHECK(collapsed.windowCollapsed);
}
// --- Totality --------------------------------------------------------------------
static void testResolveNoteIsFiniteForEveryConstructibleInput() {
// The claim that lets resolveNote have no failure path, swept rather than argued: every
// division, both denominations, the magnitude extremes the door admits plus the garbage
// it normalizes, across tempos from rejected-subnormal to rejected-astronomical.
const double inf = std::numeric_limits<double>::infinity();
const double nan = std::numeric_limits<double>::quiet_NaN();
const double magnitudes[] = {-inf, -kMaxConvertibleMagnitude, -1e300, 0.0, 1e300,
kMaxConvertibleMagnitude, inf, nan};
int accepted = 0, rejected = 0;
for (double bpm : {1e-320, 1e-306, 1e-200, 1e-6, 0.5, 120.0, 1e6, 1e100, 1e308}) {
const std::optional<Tempo> tempo = Tempo::fromBpm(bpm);
if (!tempo) { ++rejected; continue; }
++accepted;
for (int i = 0; i < kDivisionCount; ++i) {
for (double m : magnitudes) {
for (Denomination d : {Denomination::Milliseconds, Denomination::Beats}) {
const ResolvedNote r = resolveNote(
program(divisionAt(i), offsetOf(m, d), offsetOf(-m, d), 100), *tempo);
CHECK(std::isfinite(r.noteOffSeconds));
CHECK(std::isfinite(r.captureStartSeconds));
CHECK(std::isfinite(r.captureEndSeconds));
CHECK(std::isfinite(r.captureLengthSeconds()));
CHECK(r.captureLengthSeconds() >= 0.0);
}
}
}
}
// Neither half of the tempo sweep may be empty, or the loop above proves nothing.
CHECK(accepted > 0);
CHECK(rejected > 0);
}
// --- Editing via the non-stored view ---------------------------------------------
static void testWithMsViewPreservesTheStoredDenomination() {
const Tempo t = at(120.0); // one beat is 500 ms
const OffsetAmount msOffset = offsetFromMs(10.0);
const OffsetAmount editedMs = withMsView(msOffset, 40.0, t);
CHECK(editedMs.denomination == Denomination::Milliseconds);
CHECK(almostEqual(editedMs.magnitude, 40.0));
CHECK(editedMs.denomination() == Denomination::Milliseconds);
CHECK(almostEqual(editedMs.magnitude(), 40.0));
const OffsetAmount beatsOffset = offsetFromBeats(1.0);
const OffsetAmount editedBeats = withMsView(beatsOffset, 250.0, t);
CHECK(editedBeats.denomination == Denomination::Beats); // stays beats-denominated
CHECK(editedBeats.denomination() == Denomination::Beats); // stays beats-denominated
CHECK(almostEqual(offsetMs(editedBeats, t), 250.0, 1e-6)); // but reads back as 250 ms
}
@@ -378,12 +487,12 @@ static void testWithBeatsViewPreservesTheStoredDenomination() {
const Tempo t = at(120.0); // one beat is 500 ms
const OffsetAmount beatsOffset = offsetFromBeats(0.5);
const OffsetAmount editedBeats = withBeatsView(beatsOffset, 2.0, t);
CHECK(editedBeats.denomination == Denomination::Beats);
CHECK(almostEqual(editedBeats.magnitude, 2.0));
CHECK(editedBeats.denomination() == Denomination::Beats);
CHECK(almostEqual(editedBeats.magnitude(), 2.0));
const OffsetAmount msOffset = offsetFromMs(100.0);
const OffsetAmount editedMs = withBeatsView(msOffset, 1.0, t);
CHECK(editedMs.denomination == Denomination::Milliseconds); // stays ms-denominated
CHECK(editedMs.denomination() == Denomination::Milliseconds); // stays ms-denominated
CHECK(almostEqual(offsetBeats(editedMs, t), 1.0)); // but reads back as 1 beat
}
@@ -414,13 +523,19 @@ int main() {
testRedenominatedRecordDescribesTheSameWindow();
testDefaultRecordIsAQuarterNoteWithNoOffsets();
testOutOfRangeDenominationReadsAsMillisecondsEverywhere();
testUnnamedDenominationBecomesMilliseconds();
testEveryDenominationBranchingFunctionAgreesWithThePin();
testCorruptMagnitudeIsBoundedAtTheDoor();
testANanMagnitudeCannotReachTheResolvedWindow();
testWindowCollapsedFlagsAnInvertedWindow();
testWindowCollapsedIsFalseForAGenuinelyZeroLengthWindow();
testWithMsViewPreservesTheStoredDenomination();
testWithBeatsViewPreservesTheStoredDenomination();
testResolveNoteIsFiniteForEveryConstructibleInput();
if (g_fail == 0) std::printf("note_program: all tests passed\n");
else std::printf("note_program: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
+45 -3
View File
@@ -2,9 +2,10 @@
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: BPM validation (the only rejection point, which is what makes the conversions
// total); seconds-per-beat at several tempos; beats<->seconds and beats<->ms round-trips
// across tempos and signs; the proportionality between two tempos, asserted as a ratio
// rather than against any fixed seconds value.
// total) including the tempos whose reciprocal is finite but whose conversions overflow;
// seconds-per-beat at several tempos; beats<->seconds and beats<->ms round-trips across
// tempos and signs; the proportionality between two tempos, asserted as a ratio rather than
// against any fixed seconds value.
#include "../src/core/instrument/note/tempo.h"
@@ -46,6 +47,44 @@ static void testUnusableBpmIsRejected() {
CHECK(!Tempo::fromBpm(1e-310).has_value());
}
static void testBpmWhoseReciprocalIsFineButWhoseConversionsOverflowIsRejected() {
// The gap a guard on 60/bpm alone leaves open: the reciprocal is an ordinary finite
// double, and the multiply that follows it is what blows up.
CHECK(std::isfinite(60.0 / 1e-306));
CHECK(!Tempo::fromBpm(1e-306).has_value());
// The fast end fails in the other direction — the divide, not the multiply.
CHECK(!Tempo::fromBpm(1e308).has_value());
}
static void testTheGuardAdmitsEveryRealTempoAndFarBeyond() {
// The guard is structural, not musical, so it must not have narrowed onto the range of
// tempos anyone would type. The extremes here are orders of magnitude past that.
for (double bpm : {1e-200, 1e-6, 0.001, 1.0, 20.0, 120.0, 240.0, 960.0, 1e6, 1e100}) {
CHECK(Tempo::fromBpm(bpm).has_value());
}
}
static void testEveryAcceptedTempoConvertsTheWholeDomainFinitely() {
// What the guard is FOR: past it, no conversion of a magnitude the module admits can
// reach inf or NaN, in either unit or either direction.
int accepted = 0, rejected = 0;
for (double bpm : {1e-320, 1e-306, 1e-300, 1e-100, 1e-6, 0.5, 120.0, 1e6, 1e100, 1e250,
1e308}) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { ++rejected; continue; }
++accepted;
for (double m : {-kMaxConvertibleMagnitude, -1.0, 0.0, 1.0, kMaxConvertibleMagnitude}) {
CHECK(std::isfinite(t->beatsToSeconds(m)));
CHECK(std::isfinite(t->beatsToMs(m)));
CHECK(std::isfinite(t->msToBeats(m)));
CHECK(std::isfinite(t->secondsToBeats(msToSeconds(m))));
}
}
// Neither half of the sweep may be empty, or the loop above proves nothing.
CHECK(accepted > 0);
CHECK(rejected > 0);
}
// --- Conversions ---------------------------------------------------------------
static void testSecondsPerBeatFollowsBpm() {
@@ -121,6 +160,9 @@ static void testMillisecondsAreTempoFree() {
int main() {
testUsableBpmIsAccepted();
testUnusableBpmIsRejected();
testBpmWhoseReciprocalIsFineButWhoseConversionsOverflowIsRejected();
testTheGuardAdmitsEveryRealTempoAndFarBeyond();
testEveryAcceptedTempoConvertsTheWholeDomainFinitely();
testSecondsPerBeatFollowsBpm();
testBeatsToSecondsAtAKnownTempo();