note: close every value type's domain at construction, so resolveNote is finite for every constructible input

Division and OffsetAmount get single normalizing doors and private constructors; fromBpm validates by running the conversions rather than their reciprocal. Readers drop their re-clamps and default labels.
This commit is contained in:
2026-07-30 20:37:12 -04:00
parent d923b352ae
commit a80eb76c1f
11 changed files with 398 additions and 119 deletions
+37 -8
View File
@@ -13,6 +13,11 @@
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
@@ -21,7 +26,7 @@ public:
Velocity() = default;
static Velocity of(int value); // clamped into [kMin, kMax]
std::uint8_t value() const { return value_; }
constexpr std::uint8_t value() const { return value_; }
private:
std::uint8_t value_ = 100;
@@ -31,20 +36,42 @@ 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.
struct OffsetAmount {
double magnitude = 0.0;
Denomination denomination = Denomination::Milliseconds;
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);
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);
@@ -100,7 +127,7 @@ 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 = 100; // resolveNote always overwrites this; matches Velocity's own default
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;
@@ -108,6 +135,8 @@ struct ResolvedNote {
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