Merge Ξ-W1-T2: the programmed capture-signal model, its domain closed at construction
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(map)
|
||||
add_subdirectory(note)
|
||||
add_subdirectory(ui)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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** — see `OffsetAmount` in
|
||||
`note_program.h` for why.
|
||||
- **Every value type establishes its domain at construction, so every field `resolveNote`
|
||||
returns is finite for every constructible program and tempo.** `Tempo::fromBpm` rejects,
|
||||
alone, because an unusable BPM has no nearest usable one to fall to. `Division`,
|
||||
`OffsetAmount`, and `Velocity` clamp, because an off-ladder rung, an unrepresentable
|
||||
magnitude, and an out-of-range velocity each do. Each has exactly one door (`makeDivision`,
|
||||
`offsetOf`, `Velocity::of`); `Division` and `OffsetAmount` block any other path with a
|
||||
private value constructor, `Velocity` with a private member that only `of()` writes —
|
||||
either way an out-of-domain value cannot be held, only passed in. That is what lets every
|
||||
reader branch without a fallback, equality compare fields raw, and `resolveNote` return
|
||||
finite times for every constructible input with no failure path and no validity flag.
|
||||
- **The module will not tell a caller a record is junk, because a junk record cannot exist
|
||||
here.** Corruption is only visible where raw bytes are: a codec sees both the bytes it
|
||||
read and the value construction produced, and reporting the difference is the codec's job.
|
||||
Do not add a validity flag to `NoteProgram` or `ResolvedNote` to carry that signal upward
|
||||
— `windowCollapsed` describes a legal program, and is not the seed of an error channel.
|
||||
- **Does not carry a MIDI note number.** `NoteProgram` describes timing and velocity only;
|
||||
render pitch is deferred to a later additive field (Ξ-W2) rather than assumed to live
|
||||
here.
|
||||
|
||||
## 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. Beats only
|
||||
— see `musical_division.h` for why it links no tempo.
|
||||
- `tempo` — a validated project tempo plus every beats <-> seconds <-> ms conversion, and
|
||||
`kMaxConvertibleMagnitude`, the beats-or-ms ceiling the whole directory caps its domains
|
||||
to. `fromBpm` validates by running the extreme conversions rather than by testing the
|
||||
`60/bpm` reciprocal they start from — that reciprocal stays finite well past the point the
|
||||
multiply after it overflows.
|
||||
- `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** — see `tempo.h` for why. A beats *readout* that should track
|
||||
a compound meter's dotted-quarter pulse would need the time signature threaded in — it is
|
||||
not, deliberately. `src/core/ui/card_meta.cpp` is the sibling module that *does* fold
|
||||
`timeSigDenom` into its own seconds-per-beat — a different, also-correct convention for a
|
||||
different job; don't read the divergence as a bug in either.
|
||||
- **`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.
|
||||
- **`Division` and `OffsetAmount` are trivially copyable, so a `memcpy` of a wire record
|
||||
bypasses every door.** Decode field-by-field through `makeDivision`/`offsetOf` (the pattern
|
||||
`src/core/wire/bytes.h` already uses) instead — never `memcpy` raw bytes into either type.
|
||||
- **Editing the ms field of a beats-stored offset stores beats, and the ms readout will then
|
||||
move with the tempo.** `withMsView` keeps the stored denomination on purpose, so typing 250
|
||||
into the ms field of a beats offset stores 0.5 beats at 120 BPM. That is the intended
|
||||
semantic, but it is a UI-visible surprise worth a word in the popup: `redenominate` — the
|
||||
unit toggle — is the only thing that changes which denomination is stored.
|
||||
@@ -0,0 +1,13 @@
|
||||
reasampler_pure_library(musical_division SOURCES musical_division.cpp)
|
||||
# Links only musical_division — the beats-only contract (see musical_division.h) needs no
|
||||
# tempo in the link line.
|
||||
reasampler_test(musical_division LINK musical_division)
|
||||
|
||||
reasampler_pure_library(tempo SOURCES tempo.cpp)
|
||||
reasampler_test(tempo LINK tempo)
|
||||
|
||||
# note_program links exactly these two: it composes the ladder and the tempo and nothing else.
|
||||
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));
|
||||
}
|
||||
|
||||
// The underlying type is unsigned, so an out-of-enum byte can only be too large.
|
||||
DivisionModifier clampModifier(DivisionModifier m) {
|
||||
return static_cast<int>(m) < kModifierCount ? m : DivisionModifier::Straight;
|
||||
}
|
||||
|
||||
} // 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) {
|
||||
return Division(static_cast<std::int8_t>(clampExponent(quarterExponent)),
|
||||
clampModifier(modifier));
|
||||
}
|
||||
|
||||
double divisionBeats(Division d) {
|
||||
return std::ldexp(1.0, d.quarterExponent()) * modifierFactor(d.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) {
|
||||
return (d.quarterExponent() - kMinQuarterExponent) * kModifierCount
|
||||
+ static_cast<int>(d.modifier());
|
||||
}
|
||||
|
||||
std::string divisionLabel(Division d) {
|
||||
const int e = d.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 (d.modifier() == DivisionModifier::Dotted) label += '.';
|
||||
else if (d.modifier() == DivisionModifier::Triplet) label += 't';
|
||||
return label;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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>
|
||||
#include <type_traits>
|
||||
|
||||
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;
|
||||
|
||||
// The longest programmable note — the dotted top rung — so a caller composing this ladder
|
||||
// with the tempo conversions can check the two domains against each other at compile time.
|
||||
inline constexpr double kMaxDivisionBeats = (1 << kMaxQuarterExponent) * 1.5;
|
||||
|
||||
class Division;
|
||||
|
||||
// Off-ladder inputs 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.
|
||||
// An unnamed modifier byte has no nearest rung to fall to, so it takes the field's default.
|
||||
Division makeDivision(int quarterExponent, DivisionModifier modifier);
|
||||
|
||||
// In-domain by construction — `makeDivision` is the only door and it clamps BOTH fields, so
|
||||
// every reader below trusts the stored pair instead of re-clamping it, and equality compares
|
||||
// the two fields raw without disagreeing with any of them.
|
||||
class Division {
|
||||
public:
|
||||
Division() = default; // 1/4 straight
|
||||
|
||||
constexpr std::int8_t quarterExponent() const { return quarterExponent_; }
|
||||
constexpr DivisionModifier modifier() const { return modifier_; }
|
||||
|
||||
private:
|
||||
Division(std::int8_t quarterExponent, DivisionModifier modifier)
|
||||
: quarterExponent_(quarterExponent), modifier_(modifier) {}
|
||||
friend Division makeDivision(int quarterExponent, DivisionModifier modifier);
|
||||
|
||||
std::int8_t quarterExponent_ = 0;
|
||||
DivisionModifier modifier_ = DivisionModifier::Straight;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<Division, int, DivisionModifier>,
|
||||
"makeDivision must be the only way to give a Division a rung");
|
||||
|
||||
bool operator==(Division a, Division b);
|
||||
bool operator!=(Division a, Division b);
|
||||
|
||||
// Length in beats (quarter notes). Always > 0, and never above kMaxDivisionBeats.
|
||||
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,108 @@
|
||||
// note_program.cpp — see note_program.h. Pure; standard library only.
|
||||
|
||||
#include "core/instrument/note/note_program.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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 offsetOf(double magnitude, Denomination denomination) {
|
||||
const double bounded =
|
||||
std::isnan(magnitude) ? 0.0
|
||||
: (std::max)(-kMaxConvertibleMagnitude,
|
||||
(std::min)(kMaxConvertibleMagnitude, magnitude));
|
||||
const bool named = denomination == Denomination::Milliseconds
|
||||
|| denomination == Denomination::Beats;
|
||||
return OffsetAmount(bounded, named ? denomination : Denomination::Milliseconds);
|
||||
}
|
||||
|
||||
OffsetAmount offsetFromMs(double ms) { return offsetOf(ms, Denomination::Milliseconds); }
|
||||
|
||||
OffsetAmount offsetFromBeats(double beats) { return offsetOf(beats, Denomination::Beats); }
|
||||
|
||||
// Milliseconds is pinned AFTER the switch rather than by a `default:` inside it, so the
|
||||
// switch stays exhaustive over the enum and a third denomination trips switch-exhaustiveness
|
||||
// diagnostics here instead of silently resolving as ms in all three. Those diagnostics are
|
||||
// off at this project's warning level, so read it as a signpost — the tests are the gate.
|
||||
double offsetMs(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return tempo.beatsToMs(amount.magnitude());
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return amount.magnitude();
|
||||
}
|
||||
|
||||
double offsetBeats(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return amount.magnitude();
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return tempo.msToBeats(amount.magnitude());
|
||||
}
|
||||
|
||||
double offsetSeconds(OffsetAmount amount, Tempo tempo) {
|
||||
switch (amount.denomination()) {
|
||||
case Denomination::Beats: return tempo.beatsToSeconds(amount.magnitude());
|
||||
case Denomination::Milliseconds: break;
|
||||
}
|
||||
return msToSeconds(amount.magnitude());
|
||||
}
|
||||
|
||||
OffsetAmount redenominate(OffsetAmount amount, Denomination to, Tempo tempo) {
|
||||
// Defense-in-depth, not a discriminating guard: the branch below already treats any
|
||||
// non-Beats target as Milliseconds, so an unnamed `to` resolves the same way whether or
|
||||
// not it is routed through offsetOf first. Kept because a future third denomination
|
||||
// would make this the one place that still pins it.
|
||||
const Denomination target = offsetOf(0.0, to).denomination();
|
||||
if (amount.denomination() == target) return amount;
|
||||
return target == Denomination::Beats ? offsetFromBeats(offsetBeats(amount, tempo))
|
||||
: offsetFromMs(offsetMs(amount, tempo));
|
||||
}
|
||||
|
||||
OffsetAmount withMsView(OffsetAmount amount, double ms, Tempo tempo) {
|
||||
return amount.denomination() == Denomination::Beats ? offsetFromBeats(tempo.msToBeats(ms))
|
||||
: offsetFromMs(ms);
|
||||
}
|
||||
|
||||
OffsetAmount withBeatsView(OffsetAmount amount, double beats, Tempo tempo) {
|
||||
return amount.denomination() == Denomination::Beats
|
||||
? offsetFromBeats(beats)
|
||||
: offsetFromMs(tempo.beatsToMs(beats));
|
||||
}
|
||||
|
||||
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);
|
||||
const double rawEndSeconds = 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.windowCollapsed = rawEndSeconds < out.captureStartSeconds;
|
||||
out.captureEndSeconds = (std::max)(rawEndSeconds, out.captureStartSeconds);
|
||||
out.velocity = program.velocity.value();
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 (this directory's CLAUDE.md: the standing ruling).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#include "core/instrument/note/musical_division.h"
|
||||
#include "core/instrument/note/tempo.h"
|
||||
|
||||
namespace reasampler::instrument::note {
|
||||
|
||||
// The ladder and the offsets both feed the tempo conversions, so both must sit inside the
|
||||
// domain fromBpm validates — checked here because this is the one file that composes them.
|
||||
static_assert(kMaxDivisionBeats <= kMaxConvertibleMagnitude,
|
||||
"the note-length ladder must stay inside the tempo conversions' domain");
|
||||
|
||||
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]
|
||||
|
||||
constexpr 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 };
|
||||
|
||||
class OffsetAmount;
|
||||
|
||||
// The one door. Normalizes both fields so nothing downstream has to: a magnitude past
|
||||
// +/-kMaxConvertibleMagnitude clamps to it, a NaN magnitude — which names no value to clamp
|
||||
// toward — becomes zero, and a denomination outside the enum becomes Milliseconds, the
|
||||
// field's own default. A corrupt persisted record therefore resolves to a plausible offset
|
||||
// rather than an unrepresentable one, and no two readers can disagree about which.
|
||||
OffsetAmount offsetOf(double magnitude, Denomination denomination);
|
||||
OffsetAmount offsetFromMs(double ms);
|
||||
OffsetAmount offsetFromBeats(double 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.
|
||||
class OffsetAmount {
|
||||
public:
|
||||
OffsetAmount() = default;
|
||||
|
||||
constexpr double magnitude() const { return magnitude_; }
|
||||
constexpr Denomination denomination() const { return denomination_; }
|
||||
|
||||
private:
|
||||
OffsetAmount(double magnitude, Denomination denomination)
|
||||
: magnitude_(magnitude), denomination_(denomination) {}
|
||||
friend OffsetAmount offsetOf(double magnitude, Denomination denomination);
|
||||
|
||||
double magnitude_ = 0.0;
|
||||
Denomination denomination_ = Denomination::Milliseconds;
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<OffsetAmount, double, Denomination>,
|
||||
"offsetOf must be the only way to give an OffsetAmount a value");
|
||||
|
||||
bool operator==(OffsetAmount a, OffsetAmount b);
|
||||
bool operator!=(OffsetAmount a, OffsetAmount b);
|
||||
|
||||
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);
|
||||
|
||||
// Edit the magnitude via its non-stored view without changing which denomination is stored
|
||||
// — a popup's ms and beats fields both stay live no matter which one the offset was entered
|
||||
// in; only `redenominate` changes the stored denomination itself.
|
||||
OffsetAmount withMsView(OffsetAmount amount, double ms, Tempo tempo);
|
||||
OffsetAmount withBeatsView(OffsetAmount amount, double beats, 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_{};
|
||||
};
|
||||
|
||||
static_assert(!std::is_constructible_v<StartOffset, EndOffset>,
|
||||
"StartOffset and EndOffset must not be interchangeable at compile time");
|
||||
static_assert(!std::is_convertible_v<OffsetAmount, StartOffset>,
|
||||
"the anchor constructor must stay explicit");
|
||||
|
||||
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 = Velocity{}.value(); // resolveNote always overwrites this
|
||||
// True when the programmed end offset inverted the window and resolveNote collapsed it
|
||||
// to zero length instead — lets a popup explain an empty window rather than just show one.
|
||||
bool windowCollapsed = false;
|
||||
|
||||
double captureLengthSeconds() const { return captureEndSeconds - captureStartSeconds; }
|
||||
};
|
||||
|
||||
// Total: every field of the result is finite for every constructible program and tempo,
|
||||
// which is why there is no failure path here. See this directory's CLAUDE.md.
|
||||
ResolvedNote resolveNote(const NoteProgram& program, Tempo tempo);
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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;
|
||||
// Guard by running the conversions, not by testing the 60/bpm reciprocal they start
|
||||
// from: that reciprocal stays finite for BPMs whose beatsToMs has already overflowed,
|
||||
// because the conversions scale it by up to kMaxConvertibleMagnitude. Both directions
|
||||
// are checked — one overflows at an absurdly slow tempo, the other at an absurdly fast
|
||||
// one. Calling them here is what keeps the guard from drifting away from what they do.
|
||||
const Tempo candidate(beatsPerMinute);
|
||||
if (!std::isfinite(candidate.beatsToMs(kMaxConvertibleMagnitude))) return std::nullopt;
|
||||
if (!std::isfinite(candidate.msToBeats(kMaxConvertibleMagnitude))) return std::nullopt;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
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,49 @@
|
||||
// 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>
|
||||
#include <type_traits>
|
||||
|
||||
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; }
|
||||
|
||||
// The largest magnitude, in beats or in milliseconds, the conversions below are required to
|
||||
// keep finite. `fromBpm` validates against it and every caller caps its own domain to it, so
|
||||
// the two halves of the totality claim meet at one number. Astronomically above anything
|
||||
// musical — a billion milliseconds is eleven days — so nothing real is excluded.
|
||||
inline constexpr double kMaxConvertibleMagnitude = 1e9;
|
||||
|
||||
class Tempo {
|
||||
public:
|
||||
// Rejects rather than clamps, alone among this module's doors: an unusable BPM has no
|
||||
// nearest usable one to fall to. See this directory's CLAUDE.md for the rule.
|
||||
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_;
|
||||
};
|
||||
|
||||
static_assert(!std::is_default_constructible_v<Tempo>,
|
||||
"Tempo must not be constructible without a validated BPM");
|
||||
static_assert(!std::is_constructible_v<Tempo, double>,
|
||||
"fromBpm must be the only way to give a Tempo a value");
|
||||
|
||||
} // namespace reasampler::instrument::note
|
||||
Reference in New Issue
Block a user