note: land the programmed capture-signal model — division ladder, tempo resolution, anchored offsets, one record and one resolver
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(note)
|
||||
add_subdirectory(ui)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user