note: land the programmed capture-signal model — division ladder, tempo resolution, anchored offsets, one record and one resolver

This commit is contained in:
2026-07-30 19:44:15 -04:00
parent 7bd911d58b
commit 834a6ddcc7
12 changed files with 1098 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
// Standalone tests for reasampler::instrument::note::musical_division — no VST3, no REAPER,
// no framework. Same fast assert loop as the sibling pure tests.
//
// 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.
#include "../src/core/instrument/note/musical_division.h"
#include <cstdio>
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) {
const double d = a - b;
return (d < 0 ? -d : d) < 1e-12;
}
// Length in beats (quarter notes) of each straight rung, written out rather than computed,
// so a broken exponent formula cannot agree with its own mistake.
static const double kStraightBeats[kRungCount] = {
0.0625, // 1/64
0.125, // 1/32
0.25, // 1/16
0.5, // 1/8
1.0, // 1/4
2.0, // 1/2
4.0, // 1/1
8.0, // 2/1
16.0, // 4/1
32.0, // 8/1
64.0, // 16/1
128.0, // 32/1
256.0, // 64/1
};
static const char* const kStraightLabels[kRungCount] = {
"1/64", "1/32", "1/16", "1/8", "1/4", "1/2", "1/1",
"2/1", "4/1", "8/1", "16/1", "32/1", "64/1",
};
// --- The ladder ---------------------------------------------------------------
static void testLadderSpansSixtyfourthToSixtyFourWhole() {
CHECK(kRungCount == 13);
CHECK(kDivisionCount == 39);
CHECK(divisionLabel(divisionAt(0)) == "1/64");
CHECK(divisionLabel(divisionAt(kDivisionCount - 1)) == "64/1t");
}
static void testEveryStraightRungHasItsWrittenBeatLength() {
for (int rung = 0; rung < kRungCount; ++rung) {
const Division d = makeDivision(kMinQuarterExponent + rung, DivisionModifier::Straight);
CHECK(almostEqual(divisionBeats(d), kStraightBeats[rung]));
CHECK(divisionLabel(d) == kStraightLabels[rung]);
}
}
static void testDottedIsHalfAgainAndTripletIsTwoThirds() {
for (int rung = 0; rung < kRungCount; ++rung) {
const int e = kMinQuarterExponent + rung;
CHECK(almostEqual(divisionBeats(makeDivision(e, DivisionModifier::Dotted)),
kStraightBeats[rung] * 1.5));
CHECK(almostEqual(divisionBeats(makeDivision(e, DivisionModifier::Triplet)),
kStraightBeats[rung] * 2.0 / 3.0));
}
}
static void testExtremes() {
// 1/64 straight is the shortest rung; 64/1 straight is the longest.
CHECK(almostEqual(divisionBeats(makeDivision(kMinQuarterExponent, DivisionModifier::Straight)),
0.0625));
CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Straight)),
256.0));
// The dotted 64/1 is the single longest programmable note.
CHECK(almostEqual(divisionBeats(makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted)),
384.0));
// The 1/64 triplet is the shortest.
CHECK(almostEqual(divisionBeats(makeDivision(kMinQuarterExponent, DivisionModifier::Triplet)),
0.0625 * 2.0 / 3.0));
}
// --- The four named examples --------------------------------------------------
static void testNamedExamples() {
// 1/8. — an eighth is half a beat, dotted is three quarters of one.
const Division dottedEighth = makeDivision(-1, DivisionModifier::Dotted);
CHECK(almostEqual(divisionBeats(dottedEighth), 0.75));
CHECK(divisionLabel(dottedEighth) == "1/8.");
// 1/4t — a quarter is one beat, the triplet is two thirds of one.
const Division quarterTriplet = makeDivision(0, DivisionModifier::Triplet);
CHECK(almostEqual(divisionBeats(quarterTriplet), 2.0 / 3.0));
CHECK(divisionLabel(quarterTriplet) == "1/4t");
// 1/16 — a quarter of a beat.
const Division sixteenth = makeDivision(-2, DivisionModifier::Straight);
CHECK(almostEqual(divisionBeats(sixteenth), 0.25));
CHECK(divisionLabel(sixteenth) == "1/16");
// 4/1 — four whole notes, sixteen beats.
const Division fourWhole = makeDivision(4, DivisionModifier::Straight);
CHECK(almostEqual(divisionBeats(fourWhole), 16.0));
CHECK(divisionLabel(fourWhole) == "4/1");
}
// --- Picker order -------------------------------------------------------------
static void testPickerOrderIsShortestFirst() {
// Straight lengths ascend across rungs; within a rung the order is straight, dotted,
// triplet (so the index is not itself sorted by duration — only the rungs are).
for (int rung = 1; rung < kRungCount; ++rung) {
const double prev = divisionBeats(divisionAt((rung - 1) * kModifierCount));
const double here = divisionBeats(divisionAt(rung * kModifierCount));
CHECK(here > prev);
}
CHECK(divisionAt(0) == makeDivision(kMinQuarterExponent, DivisionModifier::Straight));
CHECK(divisionAt(1) == makeDivision(kMinQuarterExponent, DivisionModifier::Dotted));
CHECK(divisionAt(2) == makeDivision(kMinQuarterExponent, DivisionModifier::Triplet));
}
static void testIndexRoundTripsOverTheWholeSet() {
for (int i = 0; i < kDivisionCount; ++i) {
CHECK(divisionIndex(divisionAt(i)) == i);
}
}
static void testEverySetMemberIsDistinct() {
// No two indices name the same division, so the picker offers 39 real choices.
for (int i = 0; i < kDivisionCount; ++i) {
for (int j = i + 1; j < kDivisionCount; ++j) {
CHECK(divisionAt(i) != divisionAt(j));
}
}
}
// --- 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));
}
static void testOutOfRangeIndexClampsIntoTheSet() {
CHECK(divisionAt(-1) == divisionAt(0));
CHECK(divisionAt(kDivisionCount) == divisionAt(kDivisionCount - 1));
}
int main() {
testLadderSpansSixtyfourthToSixtyFourWhole();
testEveryStraightRungHasItsWrittenBeatLength();
testDottedIsHalfAgainAndTripletIsTwoThirds();
testExtremes();
testNamedExamples();
testPickerOrderIsShortestFirst();
testIndexRoundTripsOverTheWholeSet();
testEverySetMemberIsDistinct();
testOffLadderExponentClampsToTheNearestRung();
testOutOfRangeIndexClampsIntoTheSet();
if (g_fail == 0) std::printf("musical_division: all tests passed\n");
else std::printf("musical_division: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}
+359
View File
@@ -0,0 +1,359 @@
// 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;
// every division resolving to its duration in seconds; proportionality across two tempos;
// record equality and copy round-trip.
#include "../src/core/instrument/note/note_program.h"
#include <cmath>
#include <cstdio>
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<Tempo> 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 >= Velocity::kMin && r.velocity <= Velocity::kMax);
}
int main() {
testVelocityCarriesInRange();
testVelocityClampsOutOfRange();
testResolvedNoteCarriesTheProgrammedVelocity();
testMsOffsetReadsBackInBothDenominations();
testBeatsOffsetReadsBackInBothDenominations();
testRedenominationRoundTripsLosslessly();
testRedenominatingToTheSameUnitIsIdentity();
testStoredDenominationDecidesWhetherAnOffsetFollowsTheTempo();
testEveryDivisionResolvesToItsDuration();
testExtremeAndNamedDivisionsInSeconds();
testNoteLengthIsProportionalToTempo();
testWindowAnchorsStartToNoteOnAndEndToNoteOff();
testEndOffsetMovesWithTheNoteLength();
testBeatsDenominatedOffsetsResolveAgainstTheSuppliedTempo();
testMixedDenominationsResolveIndependently();
testNegativeEndOffsetTruncatesBeforeRelease();
testWindowNeverInverts();
testRecordRoundTripsAsAWhole();
testRecordEqualityIsSensitiveToEveryField();
testRedenominatedRecordDescribesTheSameWindow();
testDefaultRecordIsAQuarterNoteWithNoOffsets();
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;
}
+135
View File
@@ -0,0 +1,135 @@
// Standalone tests for reasampler::instrument::note::tempo — no VST3, no REAPER, no
// 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.
#include "../src/core/instrument/note/tempo.h"
#include <cmath>
#include <cstdio>
#include <limits>
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<Tempo> 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());
}
// --- Validation ---------------------------------------------------------------
static void testUsableBpmIsAccepted() {
const std::optional<Tempo> t = Tempo::fromBpm(137.5);
CHECK(t.has_value());
CHECK(t && almostEqual(t->bpm(), 137.5));
}
static void testUnusableBpmIsRejected() {
CHECK(!Tempo::fromBpm(0.0).has_value());
CHECK(!Tempo::fromBpm(-120.0).has_value());
CHECK(!Tempo::fromBpm(std::numeric_limits<double>::quiet_NaN()).has_value());
CHECK(!Tempo::fromBpm(std::numeric_limits<double>::infinity()).has_value());
}
// --- Conversions ---------------------------------------------------------------
static void testSecondsPerBeatFollowsBpm() {
CHECK(almostEqual(at(60.0).secondsPerBeat(), 1.0));
CHECK(almostEqual(at(120.0).secondsPerBeat(), 0.5));
CHECK(almostEqual(at(240.0).secondsPerBeat(), 0.25));
}
static void testBeatsToSecondsAtAKnownTempo() {
// 90 BPM: one beat is 2/3 s, so four beats are 8/3 s.
const Tempo t = at(90.0);
CHECK(almostEqual(t.beatsToSeconds(1.0), 2.0 / 3.0));
CHECK(almostEqual(t.beatsToSeconds(4.0), 8.0 / 3.0));
CHECK(almostEqual(t.secondsToBeats(8.0 / 3.0), 4.0));
}
static void testBeatsToMsAtAKnownTempo() {
// 150 BPM: one beat is 400 ms.
const Tempo t = at(150.0);
CHECK(almostEqual(t.beatsToMs(1.0), 400.0, 1e-6));
CHECK(almostEqual(t.msToBeats(400.0), 1.0));
CHECK(almostEqual(t.msToBeats(100.0), 0.25));
}
static void testMsAndBeatsRoundTripAcrossTemposAndSigns() {
const double bpms[] = {33.0, 77.3, 120.0, 174.6, 300.0};
const double values[] = {-500.0, -20.0, 0.0, 0.5, 250.0, 12345.678};
for (double bpm : bpms) {
const Tempo t = at(bpm);
for (double ms : values) {
CHECK(almostEqual(t.beatsToMs(t.msToBeats(ms)), ms, 1e-9 + 1e-9 * std::fabs(ms)));
}
for (double beats : values) {
CHECK(almostEqual(t.msToBeats(t.beatsToMs(beats)), beats,
1e-9 + 1e-9 * std::fabs(beats)));
}
}
}
static void testSecondsRoundTrip() {
const Tempo t = at(101.7);
CHECK(almostEqual(t.secondsToBeats(t.beatsToSeconds(3.25)), 3.25));
CHECK(almostEqual(t.beatsToSeconds(t.secondsToBeats(-1.75)), -1.75));
}
// --- Proportionality -----------------------------------------------------------
static void testHalvingTheTempoDoublesEveryBeatDuration() {
// The ratio is the claim; no seconds value is asserted, so the test cannot encode a
// fixed tempo of its own.
const Tempo fast = at(140.0);
const Tempo slow = at(70.0);
for (double beats : {0.0625, 0.75, 2.0 / 3.0, 16.0, 256.0}) {
CHECK(almostEqual(slow.beatsToSeconds(beats), 2.0 * fast.beatsToSeconds(beats), 1e-9));
}
}
static void testSecondsScaleInverselyWithBpm() {
const Tempo a = at(96.0);
const Tempo b = at(123.0);
const double beats = 3.5;
CHECK(almostEqual(a.beatsToSeconds(beats) / b.beatsToSeconds(beats), 123.0 / 96.0));
}
static void testMillisecondsAreTempoFree() {
// The ms<->seconds pair carries no tempo — that is what lets a ms-denominated offset
// hold still while a beats-denominated one moves.
CHECK(almostEqual(msToSeconds(250.0), 0.25));
CHECK(almostEqual(secondsToMs(1.5), 1500.0));
CHECK(almostEqual(msToSeconds(secondsToMs(0.037)), 0.037));
}
int main() {
testUsableBpmIsAccepted();
testUnusableBpmIsRejected();
testSecondsPerBeatFollowsBpm();
testBeatsToSecondsAtAKnownTempo();
testBeatsToMsAtAKnownTempo();
testMsAndBeatsRoundTripAcrossTemposAndSigns();
testSecondsRoundTrip();
testHalvingTheTempoDoublesEveryBeatDuration();
testSecondsScaleInverselyWithBpm();
testMillisecondsAreTempoFree();
if (g_fail == 0) std::printf("tempo: all tests passed\n");
else std::printf("tempo: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}