Files
reasampler/src/core/instrument/note/note_program.h
T
daniel 65f6070348 Bake window: derived note lengths carry exact durations, not ladder rungs — a long take is no longer cut at 384 beats
Hold keeps its picker. Also: one home for the %-fold, duration-ordered Hold travel, and a corrupt tail degrades to absent rather than fabricating one.
2026-08-01 21:10:38 -04:00

184 lines
7.5 KiB
C++

// 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);
// 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<NoteLength, Division>,
"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, 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 {
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