note: land the programmed capture-signal model — division ladder, tempo resolution, anchored offsets, one record and one resolver
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user