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
+1
View File
@@ -1,3 +1,4 @@
add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
+63
View File
@@ -0,0 +1,63 @@
# src/core/instrument/note — the programmed capture signal
## Scope
The pure model of the note the sampler plays to itself when it resamples: how long it
sounds, how hard, and how far around it the capture window opens. A fourth peer of
`engine/` / `map/` / `ui/` under `core/instrument/`, and pure by the same rule — no REAPER
types, no VST3 types, no host at all.
It exists as its own directory because it is neither engine (it renders nothing), mapping
(it resolves no capture and builds no `SampleData`), nor UI (it computes no geometry). It
is a performance *description* plus its arithmetic, read by two consumers that must not
diverge: the capture-signal popup that edits it and the bake that renders it.
## Invariants
- **One record, one resolver.** `NoteProgram` is the single source of truth and
`resolveNote` the single way to turn it into times. A preview that computes its own
window, or a bake that does, is the exact divergence this module exists to prevent — the
criterion is structural (one path), not "the numbers looked close."
- **The tempo comes in as a parameter.** The BPM in effect at the project cursor is read by
the shell. Nothing here may reach for it, and no tempo is hardcoded anywhere in the
directory — `Tempo` has no default and cannot be constructed without one.
- **Resolved times are rate-free seconds.** The standing ruling: no sample rate appears
here; the caller converts seconds to frames against the live rate.
- **Note length is musical-division-only.** Offsets carry the ms/beats duality; the note
length does not. A free-duration note length would make two records describe the same
performance at one tempo and different performances at another.
- **A division persists as its `{quarterExponent, modifier}` pair, never as its picker
index.** The index is presentation order and would silently re-map every saved record if
the ladder ever gained a rung or a modifier.
- **An offset stores the denomination it was entered in.** The other view is derived on
demand. Storing resolved seconds instead would make a beats-denominated offset stop
following the tempo, which is the only reason to express one in beats.
## Modules
- `musical_division` — the note-length ladder: 1/64 through 64/1 (a rung is the base-2
exponent of its length in quarter notes, -4..8), each straight, dotted (x3/2), or triplet
(x2/3); the 39-entry picker order; and the `"1/8."` / `"1/4t"` label notation. Lengths in
beats only, so it links no tempo.
- `tempo` — a validated project tempo plus every beats <-> seconds <-> ms conversion.
Construction (`Tempo::fromBpm`) is the only place a bad BPM is rejected, which is what
lets each conversion be total and every downstream resolver be failure-free.
- `note_program``Velocity` (clamped 1..127), the denominated `OffsetAmount` and its unit
toggle, the anchored `StartOffset` / `EndOffset`, the `NoteProgram` record, and
`resolveNote`.
## Gotchas
- **A beat is a quarter note.** REAPER states project tempo in quarter notes per minute
regardless of time signature, so divisions resolve with no time signature in sight. A
beats *readout* that should track a compound meter's dotted-quarter pulse would need the
time signature threaded in — it is not, deliberately.
- **`StartOffset` and `EndOffset` are distinct types on purpose.** They hold the same
payload and differ only in what they anchor to (note-on and note-off respectively);
collapsing them into one type with an anchor field makes the swap a runtime bug instead
of a compile error.
- **Signs are uniform: positive is later in time.** So Daniel's "capture from 20 ms before
note-on" is a *negative* start offset, and a negative end offset truncates before release.
Both are legal; `resolveNote` only refuses to invert the window.
- **ms <-> beats round-trips are lossless to double precision, not bit-identical.** The
conversion is a multiply/divide pair; compare with an epsilon.
+14
View File
@@ -0,0 +1,14 @@
reasampler_pure_library(musical_division SOURCES musical_division.cpp)
# Links only musical_division: the ladder is beats-only, so it must prove out with no tempo
# in the link line at all.
reasampler_test(musical_division LINK musical_division)
reasampler_pure_library(tempo SOURCES tempo.cpp)
reasampler_test(tempo LINK tempo)
# The record composes the ladder and the tempo and nothing else — the programmed signal is
# plain data, provable without the engine, the bank, or a host.
reasampler_pure_library(note_program
SOURCES note_program.cpp
LINK PUBLIC musical_division tempo)
reasampler_test(note_program LINK note_program)
@@ -0,0 +1,68 @@
// musical_division.cpp — see musical_division.h. Pure; standard library only.
#include "core/instrument/note/musical_division.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::note {
namespace {
double modifierFactor(DivisionModifier m) {
switch (m) {
case DivisionModifier::Dotted: return 1.5;
case DivisionModifier::Triplet: return 2.0 / 3.0;
case DivisionModifier::Straight: break;
}
return 1.0;
}
int clampExponent(int quarterExponent) {
return (std::max)(kMinQuarterExponent, (std::min)(kMaxQuarterExponent, quarterExponent));
}
} // namespace
bool operator==(Division a, Division b) {
return a.quarterExponent == b.quarterExponent && a.modifier == b.modifier;
}
bool operator!=(Division a, Division b) { return !(a == b); }
Division makeDivision(int quarterExponent, DivisionModifier modifier) {
Division d;
d.quarterExponent = static_cast<std::int8_t>(clampExponent(quarterExponent));
d.modifier = modifier;
return d;
}
double divisionBeats(Division d) {
const Division legal = makeDivision(d.quarterExponent, d.modifier);
return std::ldexp(1.0, legal.quarterExponent) * modifierFactor(legal.modifier);
}
Division divisionAt(int index) {
const int clamped = (std::max)(0, (std::min)(kDivisionCount - 1, index));
return makeDivision(kMinQuarterExponent + clamped / kModifierCount,
static_cast<DivisionModifier>(clamped % kModifierCount));
}
int divisionIndex(Division d) {
const Division legal = makeDivision(d.quarterExponent, d.modifier);
return (legal.quarterExponent - kMinQuarterExponent) * kModifierCount
+ static_cast<int>(legal.modifier);
}
std::string divisionLabel(Division d) {
const Division legal = makeDivision(d.quarterExponent, d.modifier);
const int e = legal.quarterExponent;
// Both branches meet at e == 2 ("1/1"): a division's written form is its length in
// whole notes, which is 2^(e-2).
std::string label = e <= 2 ? "1/" + std::to_string(1 << (2 - e))
: std::to_string(1 << (e - 2)) + "/1";
if (legal.modifier == DivisionModifier::Dotted) label += '.';
else if (legal.modifier == DivisionModifier::Triplet) label += 't';
return label;
}
} // namespace reasampler::instrument::note
@@ -0,0 +1,50 @@
// musical_division — the note-length ladder the capture signal is programmed from: 1/64
// through 64/1, each straight, dotted, or triplet. Lengths are in BEATS only; the tempo
// resolution belongs to `tempo`, which keeps this ladder provable without one.
#pragma once
#include <cstdint>
#include <string>
namespace reasampler::instrument::note {
enum class DivisionModifier : std::uint8_t {
Straight,
Dotted, // x 3/2
Triplet, // x 2/3
};
// A rung of the ladder is the base-2 exponent of its length in quarter notes: -4 is 1/64,
// 0 is 1/4, 2 is 1/1, 8 is 64/1. Holding the exponent rather than a table of literal beat
// counts keeps every straight and dotted length exactly representable in double.
inline constexpr int kMinQuarterExponent = -4;
inline constexpr int kMaxQuarterExponent = 8;
inline constexpr int kRungCount = kMaxQuarterExponent - kMinQuarterExponent + 1;
inline constexpr int kModifierCount = 3;
inline constexpr int kDivisionCount = kRungCount * kModifierCount;
struct Division {
std::int8_t quarterExponent = 0; // 1/4
DivisionModifier modifier = DivisionModifier::Straight;
};
bool operator==(Division a, Division b);
bool operator!=(Division a, Division b);
// Off-ladder exponents clamp rather than reject: the only ways to reach one are a corrupt
// persisted record or a picker bug, and the nearest legal length beats a nonsense duration.
Division makeDivision(int quarterExponent, DivisionModifier modifier);
// Length in beats (quarter notes). Always > 0.
double divisionBeats(Division d);
// Picker order: shortest rung first, straight/dotted/triplet within each rung. Index is
// presentation order only — see this directory's CLAUDE.md before persisting one.
Division divisionAt(int index);
int divisionIndex(Division d);
// The notation divisions are named in: "1/16", "1/8.", "1/4t", "4/1".
std::string divisionLabel(Division d);
} // namespace reasampler::instrument::note
+68
View File
@@ -0,0 +1,68 @@
// note_program.cpp — see note_program.h. Pure; standard library only.
#include "core/instrument/note/note_program.h"
#include <algorithm>
namespace reasampler::instrument::note {
Velocity Velocity::of(int value) {
Velocity v;
v.value_ = static_cast<std::uint8_t>((std::max)(kMin, (std::min)(kMax, value)));
return v;
}
bool operator==(Velocity a, Velocity b) { return a.value() == b.value(); }
bool operator==(OffsetAmount a, OffsetAmount b) {
return a.magnitude == b.magnitude && a.denomination == b.denomination;
}
bool operator!=(OffsetAmount a, OffsetAmount b) { return !(a == b); }
OffsetAmount offsetFromMs(double ms) { return {ms, Denomination::Milliseconds}; }
OffsetAmount offsetFromBeats(double beats) { return {beats, Denomination::Beats}; }
double offsetMs(OffsetAmount amount, Tempo tempo) {
return amount.denomination == Denomination::Milliseconds ? amount.magnitude
: tempo.beatsToMs(amount.magnitude);
}
double offsetBeats(OffsetAmount amount, Tempo tempo) {
return amount.denomination == Denomination::Beats ? amount.magnitude
: tempo.msToBeats(amount.magnitude);
}
double offsetSeconds(OffsetAmount amount, Tempo tempo) {
return amount.denomination == Denomination::Beats
? tempo.beatsToSeconds(amount.magnitude)
: msToSeconds(amount.magnitude);
}
OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo) {
if (amount.denomination == to) return amount;
return to == Denomination::Beats ? offsetFromBeats(offsetBeats(amount, tempo))
: offsetFromMs(offsetMs(amount, tempo));
}
bool operator==(const NoteProgram& a, const NoteProgram& b) {
return a.length == b.length && a.start.amount() == b.start.amount()
&& a.end.amount() == b.end.amount() && a.velocity == b.velocity;
}
bool operator!=(const NoteProgram& a, const NoteProgram& b) { return !(a == b); }
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo) {
ResolvedNote out;
out.noteOffSeconds = tempo.beatsToSeconds(divisionBeats(program.length));
out.captureStartSeconds = offsetSeconds(program.start.amount(), tempo);
out.captureEndSeconds = out.noteOffSeconds + offsetSeconds(program.end.amount(), tempo);
// An inverted window has no meaning to a renderer, so a far-negative end offset yields a
// zero-length capture the caller can reject rather than a negative one it cannot.
out.captureEndSeconds = (std::max)(out.captureEndSeconds, out.captureStartSeconds);
out.velocity = program.velocity.value();
return out;
}
} // namespace reasampler::instrument::note
+99
View File
@@ -0,0 +1,99 @@
// note_program — the programmed capture signal: one note length, one velocity, two anchored
// offsets, and the one resolver a preview and a bake must share.
//
// Resolved times are rate-free SECONDS relative to note-on; the caller converts against the
// live sample rate.
#pragma once
#include <cstdint>
#include "core/instrument/note/musical_division.h"
#include "core/instrument/note/tempo.h"
namespace reasampler::instrument::note {
class Velocity {
public:
static constexpr int kMin = 1; // 0 is note-off in MIDI; a programmed note must sound
static constexpr int kMax = 127;
Velocity() = default;
static Velocity of(int value); // clamped into [kMin, kMax]
std::uint8_t value() const { return value_; }
private:
std::uint8_t value_ = 100;
};
bool operator==(Velocity a, Velocity b);
enum class Denomination : std::uint8_t { Milliseconds, Beats };
// One magnitude, in the denomination it was entered in; the other view is derived on demand
// and never stored. Which one was entered is itself the intent: a beats offset must follow a
// tempo change and a ms offset must hold still, and only a stored denomination says which.
struct OffsetAmount {
double magnitude = 0.0;
Denomination denomination = Denomination::Milliseconds;
};
bool operator==(OffsetAmount a, OffsetAmount b);
bool operator!=(OffsetAmount a, OffsetAmount b);
OffsetAmount offsetFromMs(double ms);
OffsetAmount offsetFromBeats(double beats);
double offsetMs(OffsetAmount amount, Tempo tempo);
double offsetBeats(OffsetAmount amount, Tempo tempo);
double offsetSeconds(OffsetAmount amount, Tempo tempo);
// The unit toggle: the same instant restated in the other denomination.
OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo);
// Two types rather than one carrying an anchor field: the anchor is then unswappable at
// compile time. Sign is uniform — positive is later in time — so a capture that opens before
// the note is a negative start offset, and a negative end offset truncates before release.
class StartOffset {
public:
StartOffset() = default;
explicit StartOffset(OffsetAmount amount) : amount_(amount) {}
OffsetAmount amount() const { return amount_; }
private:
OffsetAmount amount_{};
};
class EndOffset {
public:
EndOffset() = default;
explicit EndOffset(OffsetAmount amount) : amount_(amount) {}
OffsetAmount amount() const { return amount_; }
private:
OffsetAmount amount_{};
};
struct NoteProgram {
Division length{};
StartOffset start{};
EndOffset end{};
Velocity velocity{};
};
bool operator==(const NoteProgram& a, const NoteProgram& b);
bool operator!=(const NoteProgram& a, const NoteProgram& b);
struct ResolvedNote {
double noteOffSeconds = 0.0; // == the note's sounding length, note-on being 0
double captureStartSeconds = 0.0; // negative when the capture opens before the note
double captureEndSeconds = 0.0;
std::uint8_t velocity = 1;
double captureLengthSeconds() const { return captureEndSeconds - captureStartSeconds; }
};
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo);
} // namespace reasampler::instrument::note
+27
View File
@@ -0,0 +1,27 @@
// tempo.cpp — see tempo.h. Pure; standard library only.
#include "core/instrument/note/tempo.h"
#include <cmath>
namespace reasampler::instrument::note {
namespace {
constexpr double kSecondsPerMinute = 60.0;
} // namespace
std::optional<Tempo> Tempo::fromBpm(double beatsPerMinute) {
if (!std::isfinite(beatsPerMinute) || beatsPerMinute <= 0.0) return std::nullopt;
return Tempo(beatsPerMinute);
}
double Tempo::secondsPerBeat() const { return kSecondsPerMinute / bpm_; }
double Tempo::beatsToSeconds(double beats) const { return beats * secondsPerBeat(); }
double Tempo::secondsToBeats(double seconds) const { return seconds / secondsPerBeat(); }
double Tempo::beatsToMs(double beats) const { return secondsToMs(beatsToSeconds(beats)); }
double Tempo::msToBeats(double ms) const { return secondsToBeats(msToSeconds(ms)); }
} // namespace reasampler::instrument::note
+37
View File
@@ -0,0 +1,37 @@
// tempo — a validated project tempo and every beats <-> seconds <-> ms conversion a
// beat-denominated capture value resolves through.
//
// A BEAT IS A QUARTER NOTE — REAPER states project tempo in quarter notes per minute
// regardless of time signature, so a division resolves without one.
#pragma once
#include <optional>
namespace reasampler::instrument::note {
inline constexpr double kMsPerSecond = 1000.0;
constexpr double msToSeconds(double ms) { return ms / kMsPerSecond; }
constexpr double secondsToMs(double seconds) { return seconds * kMsPerSecond; }
class Tempo {
public:
// The only place a bad BPM is rejected, which is what lets every conversion below be
// total — no resolver downstream needs a failure path.
static std::optional<Tempo> fromBpm(double beatsPerMinute);
double bpm() const { return bpm_; }
double secondsPerBeat() const;
double beatsToSeconds(double beats) const;
double secondsToBeats(double seconds) const;
double beatsToMs(double beats) const;
double msToBeats(double ms) const;
private:
explicit Tempo(double beatsPerMinute) : bpm_(beatsPerMinute) {}
double bpm_;
};
} // namespace reasampler::instrument::note
+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;
}