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