// 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; 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 #include #include using namespace reasampler::instrument::note; 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 almostEqual(double a, double b, double eps = 1e-9) { return std::fabs(a - b) < eps; } static Tempo at(double bpm) { const std::optional t = Tempo::fromBpm(bpm); if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; } return t.value_or(Tempo::fromBpm(120.0).value()); } // Beats per straight rung, written out rather than computed — see test_musical_division. static const double kStraightBeats[kRungCount] = { 0.0625, 0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, }; static NoteProgram program(Division length, OffsetAmount start, OffsetAmount end, int velocity) { NoteProgram p; p.length = length; p.start = StartOffset(start); p.end = EndOffset(end); p.velocity = Velocity::of(velocity); return p; } // --- Velocity ------------------------------------------------------------------ static void testVelocityCarriesInRange() { CHECK(Velocity::of(1).value() == 1); CHECK(Velocity::of(96).value() == 96); CHECK(Velocity::of(127).value() == 127); } static void testVelocityClampsOutOfRange() { // 0 is note-off in MIDI: a programmed note that does not sound is never the intent. CHECK(Velocity::of(0).value() == 1); CHECK(Velocity::of(-40).value() == 1); CHECK(Velocity::of(128).value() == 127); CHECK(Velocity::of(9000).value() == 127); } static void testResolvedNoteCarriesTheProgrammedVelocity() { const Tempo t = at(120.0); CHECK(resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0), offsetFromMs(0.0), 96), t) .velocity == 96); CHECK(resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0), offsetFromMs(0.0), 0), t) .velocity == 1); } // --- The denomination seam ----------------------------------------------------- static void testMsOffsetReadsBackInBothDenominations() { // 120 BPM: one beat is 500 ms, so 250 ms is half a beat. const Tempo t = at(120.0); const OffsetAmount a = offsetFromMs(250.0); CHECK(almostEqual(offsetMs(a, t), 250.0)); CHECK(almostEqual(offsetBeats(a, t), 0.5)); CHECK(almostEqual(offsetSeconds(a, t), 0.25)); } static void testBeatsOffsetReadsBackInBothDenominations() { // 80 BPM: one beat is 750 ms. const Tempo t = at(80.0); const OffsetAmount a = offsetFromBeats(2.0); CHECK(almostEqual(offsetBeats(a, t), 2.0)); CHECK(almostEqual(offsetMs(a, t), 1500.0, 1e-6)); CHECK(almostEqual(offsetSeconds(a, t), 1.5)); } static void testRedenominationRoundTripsLosslessly() { const double bpms[] = {44.0, 91.7, 120.0, 200.0}; const double magnitudes[] = {-500.0, -20.0, 0.0, 0.25, 333.0}; for (double bpm : bpms) { const Tempo t = at(bpm); for (double ms : magnitudes) { 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))); // Re-denominating never moves the instant it names. CHECK(almostEqual(offsetSeconds(there, t), offsetSeconds(original, t))); } for (double beats : magnitudes) { const OffsetAmount original = offsetFromBeats(beats); const OffsetAmount back = redenominate(redenominate(original, Denomination::Milliseconds, t), Denomination::Beats, t); CHECK(almostEqual(back.magnitude(), beats, 1e-9 + 1e-9 * std::fabs(beats))); } } } static void testRedenominatingToTheSameUnitIsIdentity() { const Tempo t = at(120.0); const OffsetAmount a = offsetFromMs(37.0); CHECK(redenominate(a, Denomination::Milliseconds, t) == a); } static void testStoredDenominationDecidesWhetherAnOffsetFollowsTheTempo() { // The whole reason the denomination is stored: at half the tempo the beats offset is // twice as long in seconds, the ms offset unchanged. const OffsetAmount inMs = offsetFromMs(500.0); const OffsetAmount inBeats = offsetFromBeats(1.0); const Tempo fast = at(120.0); const Tempo slow = at(60.0); CHECK(almostEqual(offsetSeconds(inMs, fast), offsetSeconds(inMs, slow))); CHECK(almostEqual(offsetSeconds(inBeats, slow), 2.0 * offsetSeconds(inBeats, fast))); } // --- Note length in seconds ---------------------------------------------------- static void testEveryDivisionResolvesToItsDuration() { // 120 BPM: one beat is 0.5 s, so a division's length in seconds is half its beats. const Tempo t = at(120.0); const OffsetAmount none = offsetFromMs(0.0); for (int rung = 0; rung < kRungCount; ++rung) { const int e = kMinQuarterExponent + rung; const double straight = kStraightBeats[rung] * 0.5; CHECK(almostEqual( resolveNote(program(makeDivision(e, DivisionModifier::Straight), none, none, 100), t) .noteOffSeconds, straight, 1e-9 + 1e-9 * straight)); CHECK(almostEqual( resolveNote(program(makeDivision(e, DivisionModifier::Dotted), none, none, 100), t) .noteOffSeconds, straight * 1.5, 1e-9 + 1e-9 * straight)); CHECK(almostEqual( resolveNote(program(makeDivision(e, DivisionModifier::Triplet), none, none, 100), t) .noteOffSeconds, straight * 2.0 / 3.0, 1e-9 + 1e-9 * straight)); } } static void testExtremeAndNamedDivisionsInSeconds() { // 120 BPM: one beat is 0.5 s. const Tempo t = at(120.0); const OffsetAmount none = offsetFromMs(0.0); struct Case { Division d; double seconds; }; const Case cases[] = { {makeDivision(kMinQuarterExponent, DivisionModifier::Straight), 0.03125}, // 1/64 {makeDivision(kMaxQuarterExponent, DivisionModifier::Straight), 128.0}, // 64/1 {makeDivision(-1, DivisionModifier::Dotted), 0.375}, // 1/8. {makeDivision(0, DivisionModifier::Triplet), 1.0 / 3.0}, // 1/4t {makeDivision(-2, DivisionModifier::Straight), 0.125}, // 1/16 {makeDivision(4, DivisionModifier::Straight), 8.0}, // 4/1 }; for (const Case& c : cases) { CHECK(almostEqual(resolveNote(program(c.d, none, none, 100), t).noteOffSeconds, c.seconds, 1e-9 + 1e-9 * c.seconds)); } } static void testNoteLengthIsProportionalToTempo() { // Ratio only — no seconds value is asserted here, so the module's tempo-freedom is what // is under test rather than any particular rate. const OffsetAmount none = offsetFromMs(0.0); const Tempo fast = at(160.0); const Tempo slow = at(40.0); for (int i = 0; i < kDivisionCount; ++i) { const NoteProgram p = program(divisionAt(i), none, none, 100); CHECK(almostEqual(resolveNote(p, slow).noteOffSeconds, 4.0 * resolveNote(p, fast).noteOffSeconds, 1e-9)); } } // --- The resolved window ------------------------------------------------------- static void testWindowAnchorsStartToNoteOnAndEndToNoteOff() { // 120 BPM, 1/4 note = 0.5 s. Daniel's case: open 20 ms before note-on, close 500 ms // after note-off. const Tempo t = at(120.0); const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromMs(500.0), 96), t); CHECK(almostEqual(r.noteOffSeconds, 0.5)); CHECK(almostEqual(r.captureStartSeconds, -0.020)); // note-on is 0, so the pre-roll is negative CHECK(almostEqual(r.captureEndSeconds, 1.0)); // 0.5 note-off + 0.5 tail CHECK(almostEqual(r.captureLengthSeconds(), 1.02)); CHECK(r.velocity == 96); } static void testEndOffsetMovesWithTheNoteLength() { // The end offset anchors to note-off, so lengthening the note moves the window's end by // the same amount and leaves its start alone. const Tempo t = at(120.0); const OffsetAmount start = offsetFromMs(-20.0); const OffsetAmount end = offsetFromMs(500.0); const ResolvedNote quarter = resolveNote(program(makeDivision(0, DivisionModifier::Straight), start, end, 100), t); const ResolvedNote half = resolveNote(program(makeDivision(1, DivisionModifier::Straight), start, end, 100), t); CHECK(almostEqual(half.captureStartSeconds, quarter.captureStartSeconds)); CHECK(almostEqual(half.captureEndSeconds - quarter.captureEndSeconds, 0.5)); } static void testBeatsDenominatedOffsetsResolveAgainstTheSuppliedTempo() { // 1/4 note, start -1/2 beat, end +1 beat. At 120 BPM (0.5 s/beat): note-off 0.5, // window -0.25 .. 1.0. At 60 BPM every one of those doubles. const NoteProgram p = program(makeDivision(0, DivisionModifier::Straight), offsetFromBeats(-0.5), offsetFromBeats(1.0), 100); const ResolvedNote fast = resolveNote(p, at(120.0)); CHECK(almostEqual(fast.captureStartSeconds, -0.25)); CHECK(almostEqual(fast.captureEndSeconds, 1.0)); const ResolvedNote slow = resolveNote(p, at(60.0)); CHECK(almostEqual(slow.captureStartSeconds, -0.5)); CHECK(almostEqual(slow.captureEndSeconds, 2.0)); } static void testMixedDenominationsResolveIndependently() { // A ms pre-roll and a beats tail on one record: halving the tempo moves the tail only. const NoteProgram p = program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromBeats(1.0), 100); const ResolvedNote fast = resolveNote(p, at(120.0)); const ResolvedNote slow = resolveNote(p, at(60.0)); CHECK(almostEqual(fast.captureStartSeconds, -0.020)); CHECK(almostEqual(slow.captureStartSeconds, -0.020)); CHECK(almostEqual(fast.captureEndSeconds, 1.0)); CHECK(almostEqual(slow.captureEndSeconds, 2.0)); } static void testNegativeEndOffsetTruncatesBeforeRelease() { // 1/2 note at 120 BPM is 1.0 s; closing 200 ms early ends the window at 0.8 s. const Tempo t = at(120.0); const ResolvedNote r = resolveNote(program(makeDivision(1, DivisionModifier::Straight), offsetFromMs(0.0), offsetFromMs(-200.0), 100), t); CHECK(almostEqual(r.noteOffSeconds, 1.0)); CHECK(almostEqual(r.captureEndSeconds, 0.8)); CHECK(almostEqual(r.captureLengthSeconds(), 0.8)); } static void testWindowNeverInverts() { // An end offset past the window's own start collapses the window rather than inverting it. const Tempo t = at(120.0); const ResolvedNote r = resolveNote(program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0), offsetFromMs(-5000.0), 100), t); CHECK(almostEqual(r.captureStartSeconds, 0.0)); CHECK(almostEqual(r.captureEndSeconds, 0.0)); CHECK(r.captureLengthSeconds() >= 0.0); } // --- The record ---------------------------------------------------------------- static void testRecordRoundTripsAsAWhole() { const NoteProgram original = program(makeDivision(-1, DivisionModifier::Dotted), offsetFromMs(-20.0), offsetFromBeats(2.0), 96); const NoteProgram copy = original; CHECK(copy == original); CHECK(copy.length == makeDivision(-1, DivisionModifier::Dotted)); CHECK(copy.start.amount() == offsetFromMs(-20.0)); CHECK(copy.end.amount() == offsetFromBeats(2.0)); CHECK(copy.velocity.value() == 96); // Resolving reads the record and leaves it alone, so a preview cannot drift the state a // later bake reads. resolveNote(original, at(120.0)); CHECK(copy == original); } static void testRecordEqualityIsSensitiveToEveryField() { const NoteProgram base = program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromMs(500.0), 96); CHECK(base != program(makeDivision(0, DivisionModifier::Dotted), offsetFromMs(-20.0), offsetFromMs(500.0), 96)); CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-21.0), offsetFromMs(500.0), 96)); CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromMs(501.0), 96)); CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromMs(500.0), 97)); // Same magnitude, different denomination is a different record even where one tempo // makes them resolve alike. CHECK(base != program(makeDivision(0, DivisionModifier::Straight), offsetFromBeats(-20.0), offsetFromMs(500.0), 96)); } static void testRedenominatedRecordDescribesTheSameWindow() { const Tempo t = at(133.0); const NoteProgram original = program(makeDivision(-2, DivisionModifier::Triplet), offsetFromMs(-35.0), offsetFromMs(420.0), 64); NoteProgram restated = original; restated.start = StartOffset(redenominate(original.start.amount(), Denomination::Beats, t)); restated.end = EndOffset(redenominate(original.end.amount(), Denomination::Beats, t)); const ResolvedNote a = resolveNote(original, t); const ResolvedNote b = resolveNote(restated, t); CHECK(restated != original); // the record changed... CHECK(almostEqual(a.captureStartSeconds, b.captureStartSeconds)); // ...the window did not CHECK(almostEqual(a.captureEndSeconds, b.captureEndSeconds)); } static void testDefaultRecordIsAQuarterNoteWithNoOffsets() { const Tempo t = at(120.0); const ResolvedNote r = resolveNote(NoteProgram{}, t); CHECK(almostEqual(r.noteOffSeconds, 0.5)); CHECK(almostEqual(r.captureStartSeconds, 0.0)); CHECK(almostEqual(r.captureEndSeconds, 0.5)); CHECK(r.velocity == 100); // NoteProgram{}'s default Velocity, documented in note_program.h } // --- The door: what a corrupt persisted field becomes ---------------------------- 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(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), 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(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); // Measured (mutate offsetOf to a pass-through): still passes. offsetFromBeats always // tags its result Beats, so this holds regardless of whether corrupt was pinned — it // does not discriminate the pin. CHECK(redenominate(corrupt, Denomination::Beats, t).denomination() == Denomination::Beats); // Reported measured (withMsView reverted to its pre-domain-closure form): still passes. // corrupt already equals asMs by this point, and withMsView is a pure function of its // argument, so this line cannot discriminate anything withMsView-specific — it is a // restatement of the equality above. CHECK(withMsView(corrupt, 40.0, t) == withMsView(asMs, 40.0, t)); CHECK(withMsView(corrupt, 40.0, t).denomination() == Denomination::Milliseconds); // Measured (mutate offsetOf to a pass-through): still passes. withBeatsView only branches // on `== Beats`; any non-Beats value — pinned or raw corrupt — takes the same ms-based // else branch, so this does not discriminate the pin either. 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)); // Reported measured (offsetOf(0.0, to) replaced with `= to;`): still passes, for the // same reason as above — `target == Beats` is false whether target is pinned or raw, so // this always takes the ms branch and cannot discriminate the door (see the comment on // that line in note_program.cpp). CHECK(redenominate(offsetFromBeats(1.0), static_cast(7), t) == offsetFromMs(500.0)); } static void testCorruptMagnitudeIsBoundedAtTheDoor() { const double inf = std::numeric_limits::infinity(); const double nan = std::numeric_limits::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::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 ------------------------------------------------------------- static void testWindowCollapsedFlagsAnInvertedWindow() { const Tempo t = at(120.0); const ResolvedNote inverted = resolveNote( program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0), offsetFromMs(-5000.0), 100), t); CHECK(inverted.windowCollapsed); const ResolvedNote normal = resolveNote( program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0), offsetFromMs(500.0), 100), t); 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::infinity(); const double nan = std::numeric_limits::quiet_NaN(); const double magnitudes[] = {-inf, -kMaxConvertibleMagnitude, -1e300, 0.0, 1e300, kMaxConvertibleMagnitude, inf, nan}; int accepted = 0, rejected = 0; // 1e-294/1e-295 bracket the accept/reject edge (measured ~3.34e-295) so the sweep // actually approaches it rather than jumping past it by ~95 orders of magnitude. for (double bpm : {1e-320, 1e-306, 1e-295, 1e-294, 1e-200, 1e-6, 0.5, 120.0, 1e6, 1e100, 1e308}) { const std::optional 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)); const OffsetAmount beatsOffset = offsetFromBeats(1.0); const OffsetAmount editedBeats = withMsView(beatsOffset, 250.0, t); CHECK(editedBeats.denomination() == Denomination::Beats); // stays beats-denominated CHECK(almostEqual(offsetMs(editedBeats, t), 250.0, 1e-6)); // but reads back as 250 ms } 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)); const OffsetAmount msOffset = offsetFromMs(100.0); const OffsetAmount editedMs = withBeatsView(msOffset, 1.0, t); CHECK(editedMs.denomination() == Denomination::Milliseconds); // stays ms-denominated CHECK(almostEqual(offsetBeats(editedMs, t), 1.0)); // but reads back as 1 beat } int main() { testVelocityCarriesInRange(); testVelocityClampsOutOfRange(); testResolvedNoteCarriesTheProgrammedVelocity(); testMsOffsetReadsBackInBothDenominations(); testBeatsOffsetReadsBackInBothDenominations(); testRedenominationRoundTripsLosslessly(); testRedenominatingToTheSameUnitIsIdentity(); testStoredDenominationDecidesWhetherAnOffsetFollowsTheTempo(); testEveryDivisionResolvesToItsDuration(); testExtremeAndNamedDivisionsInSeconds(); testNoteLengthIsProportionalToTempo(); testWindowAnchorsStartToNoteOnAndEndToNoteOff(); testEndOffsetMovesWithTheNoteLength(); testBeatsDenominatedOffsetsResolveAgainstTheSuppliedTempo(); testMixedDenominationsResolveIndependently(); testNegativeEndOffsetTruncatesBeforeRelease(); testWindowNeverInverts(); testRecordRoundTripsAsAWhole(); testRecordEqualityIsSensitiveToEveryField(); testRedenominatedRecordDescribesTheSameWindow(); testDefaultRecordIsAQuarterNoteWithNoOffsets(); 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; }