// 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 #include #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, "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); // The largest exact note length, in seconds — the ms ceiling restated in the unit an exact // length is entered in, so a length and an offset cap at the same instant. inline constexpr double kMaxLengthSeconds = msToSeconds(kMaxConvertibleMagnitude); class NoteLength; // The two doors. A DERIVED length is exact: `lengthOfSeconds` is what every computed duration // takes, and rounding one onto the ladder is what truncates a source longer than the ladder's // top rung. A PICKED length is a `Division`, because a picker's rungs are the point. Both // clamp: a negative or NaN duration names no length and becomes zero, a magnitude past // kMaxLengthSeconds clamps to it, and `makeDivision` already holds the rung's own domain. NoteLength lengthOfDivision(Division division); NoteLength lengthOfSeconds(double seconds); // One length, in whichever denomination its door established. There is no accessor per // denomination: `noteLengthSeconds` resolves both, so no reader can pick the wrong one. class NoteLength { public: NoteLength() = default; // a quarter note — Division's own default private: NoteLength(Division division, double seconds, bool exact) : division_(division), seconds_(seconds), exact_(exact) {} friend NoteLength lengthOfDivision(Division division); friend NoteLength lengthOfSeconds(double seconds); friend double noteLengthSeconds(NoteLength length, Tempo tempo); friend bool operator==(NoteLength a, NoteLength b); Division division_{}; double seconds_ = 0.0; bool exact_ = false; }; static_assert(!std::is_constructible_v, "lengthOfDivision must be the only way to give a NoteLength a rung"); bool operator==(NoteLength a, NoteLength b); bool operator!=(NoteLength a, NoteLength b); double noteLengthSeconds(NoteLength length, 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 and EndOffset must not be interchangeable at compile time"); static_assert(!std::is_convertible_v, "the anchor constructor must stay explicit"); struct NoteProgram { NoteLength 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