894 lines
37 KiB
Rust
894 lines
37 KiB
Rust
//! The tempo map: the function mapping musical positions to wall-clock
|
|
//! positions (Chapter 3 §"Tempo and the Tempo Map").
|
|
//!
|
|
//! This is the data model plus the **sanctioned closed-form conversion stub**
|
|
//! (QUICKSTART "Don't do these": *"Stub `wallclock_to_musical` with linear
|
|
//! segments only. For curve segments, return … a diagnostic noting that curve
|
|
//! integration is not yet implemented."*). Conversion integrates the piecewise
|
|
//! tempo map over its [`TempoSegment`]s: [`TempoShape::Constant`],
|
|
//! [`TempoShape::Linear`], and [`TempoShape::Exponential`] segments have a
|
|
//! closed-form solution (Chapter 3 §"Conversion") and are integrated here;
|
|
//! [`TempoShape::Curve`] needs the deferred numerical-integration algorithm
|
|
//! (one of the four open canonical algorithms, QUICKSTART intro) and is
|
|
//! reported as [`TempoError::CurveIntegrationUnsupported`], never computed
|
|
//! wrongly.
|
|
//!
|
|
//! Segment boundaries are symbolic [`TimeAnchor`]s; resolving them to musical
|
|
//! positions in general needs the score graph, so the conversion takes a
|
|
//! resolver closure ([`TempoMap::musical_to_wallclock_with`]). The no-argument
|
|
//! [`TempoMap::musical_to_wallclock`] uses a self-contained resolver that
|
|
//! places `Region`-start-relative segment anchors (the natural anchoring for a
|
|
//! region-local tempo map); anchors it cannot place make the conversion report
|
|
//! [`TempoError::PiecewiseIntegrationUnsupported`] rather than guess.
|
|
//!
|
|
//! **Determinism and tolerance.** The inverse [`TempoMap::wallclock_to_musical`]
|
|
//! uses a deterministic continued-fraction rational approximation with
|
|
//! documented bounds ([`INVERSION_MAX_ITERATIONS`], [`INVERSION_MAX_DENOMINATOR`],
|
|
//! [`inversion_tolerance`]) — the documented tolerance and iteration
|
|
//! bounds Chapter 3 §"Conversion" requires of any numerical inversion. The
|
|
//! residual is a typed [`Tolerance`] of class
|
|
//! [`epiphany_determinism::ToleranceClass::TempoIntegration`].
|
|
//!
|
|
//! Conversion here is **advisory** (it uses `f64`), not canonical state:
|
|
//! musical time is the exact rational and wall-clock is exact nanoseconds
|
|
//! (Appendix D §"Exact and Quantized Representations").
|
|
|
|
use epiphany_determinism::{CanonicalF64, Tolerance, ToleranceClass, ToleranceGovernance};
|
|
|
|
use crate::time::{
|
|
MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor, WallClockTime,
|
|
};
|
|
|
|
/// A tempo: beats per minute at a given beat unit (Chapter 3). A quarter-note
|
|
/// BPM and a dotted-quarter BPM at the same numeric value differ in rate, so the
|
|
/// beat unit is part of the tempo.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct Tempo {
|
|
bpm: CanonicalF64,
|
|
beat_unit: MusicalDuration,
|
|
}
|
|
|
|
impl Tempo {
|
|
/// Builds a tempo, rejecting a non-finite or non-positive BPM and a
|
|
/// non-positive beat unit.
|
|
pub fn new(bpm: f64, beat_unit: MusicalDuration) -> Option<Self> {
|
|
if bpm > 0.0 && beat_unit.is_positive() {
|
|
CanonicalF64::new(bpm).map(|bpm| Tempo { bpm, beat_unit })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Quarter-note BPM (the common case): `bpm` beats, each a quarter note.
|
|
pub fn quarter(bpm: f64) -> Option<Self> {
|
|
Tempo::new(bpm, MusicalDuration(crate::time::RationalTime::new(1, 4)?))
|
|
}
|
|
|
|
/// Beats per minute.
|
|
pub fn bpm(&self) -> f64 {
|
|
self.bpm.get()
|
|
}
|
|
|
|
/// The beat unit (in whole notes).
|
|
pub fn beat_unit(&self) -> &MusicalDuration {
|
|
&self.beat_unit
|
|
}
|
|
|
|
/// Seconds per whole note at this tempo: one beat (`beat_unit` whole notes)
|
|
/// lasts `60/bpm` seconds, so a whole note lasts
|
|
/// `(60/bpm) / beat_unit` seconds.
|
|
pub fn seconds_per_whole_note(&self) -> f64 {
|
|
let beat_whole_notes = self.beat_unit.rational().to_f64();
|
|
(60.0 / self.bpm.get()) / beat_whole_notes
|
|
}
|
|
|
|
/// Speed in whole notes per second: the reciprocal of
|
|
/// [`Tempo::seconds_per_whole_note`]. Strictly positive (the constructor
|
|
/// rejects a non-positive bpm or beat unit), which is what keeps the
|
|
/// musical→wall-clock mapping monotonically increasing (Chapter 3
|
|
/// §"Conversion": musical time always advances).
|
|
pub fn whole_notes_per_second(&self) -> f64 {
|
|
let beat_whole_notes = self.beat_unit.rational().to_f64();
|
|
(self.bpm.get() * beat_whole_notes) / 60.0
|
|
}
|
|
}
|
|
|
|
/// The shape of a tempo change over a segment (Chapter 3).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum TempoShape {
|
|
Constant,
|
|
Linear,
|
|
Exponential,
|
|
/// Arbitrary curve (control points carried by the deferred companion).
|
|
Curve,
|
|
}
|
|
|
|
/// A piecewise tempo segment (Chapter 3). Boundaries are [`TimeAnchor`]s; the
|
|
/// `end`/`end_tempo` are `None` for an open constant segment.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct TempoSegment {
|
|
pub start: TimeAnchor,
|
|
pub end: Option<TimeAnchor>,
|
|
pub start_tempo: Tempo,
|
|
pub end_tempo: Option<Tempo>,
|
|
pub shape: TempoShape,
|
|
}
|
|
|
|
/// The tempo map (Chapter 3). `initial` applies before the first segment.
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct TempoMap {
|
|
pub initial: Option<Tempo>,
|
|
pub segments: Vec<TempoSegment>,
|
|
}
|
|
|
|
/// Why a tempo conversion could not be performed.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum TempoError {
|
|
/// No tempo is defined (empty map with no `initial`).
|
|
NoTempo,
|
|
/// Curve integration is not implemented in v0 (deferred open algorithm,
|
|
/// QUICKSTART; Appendix D §"Open Algorithm Hooks"). [`TempoShape::Curve`]
|
|
/// segments need the deferred numerical integrator.
|
|
CurveIntegrationUnsupported,
|
|
/// A segment boundary [`TimeAnchor`] could not be resolved to a musical
|
|
/// position with the available resolver, so the piecewise map could not be
|
|
/// placed on the musical timeline. Resolution in general needs the score
|
|
/// graph (Chapter 3 §"Time Anchors"); this is reported, never guessed.
|
|
PiecewiseIntegrationUnsupported,
|
|
/// The tempo map's segments are not well-formed: out of order, overlapping,
|
|
/// a non-`Constant` segment missing its `end_tempo`, or an open
|
|
/// (`end == None`) non-`Constant` segment (Chapter 3 §"Tempo and the Tempo
|
|
/// Map"). The graph-invariant checker reports the same conditions
|
|
/// structurally (invariant on the tempo map).
|
|
MalformedTempoMap,
|
|
/// A conversion result fell outside the representable range (a non-finite
|
|
/// or out-of-`i64` nanosecond value), so it is reported rather than
|
|
/// silently saturated (Appendix D §"Floating-Point Values").
|
|
ConversionOverflow,
|
|
}
|
|
|
|
/// Maximum continued-fraction iterations in [`TempoMap::wallclock_to_musical`]
|
|
/// (the documented iteration bound Chapter 3 §"Conversion" requires).
|
|
pub const INVERSION_MAX_ITERATIONS: u32 = 64;
|
|
/// Maximum denominator the inverse will introduce, in whole-note units. Keeps
|
|
/// recovered rhythms simple (`1/3`, `1/12`, …) instead of spurious large ratios.
|
|
pub const INVERSION_MAX_DENOMINATOR: u64 = 1_000_000;
|
|
/// Absolute residual magnitude of the inverse, in whole notes. Comfortably
|
|
/// larger than the half-nanosecond rounding of the forward direction, so an
|
|
/// ordinary rhythm round-trips, yet far smaller than any musical distinction.
|
|
/// The typed tolerance built on it is [`inversion_tolerance`]; this raw value
|
|
/// never leaves the module.
|
|
const INVERSION_TOLERANCE_WHOLE_NOTES: f64 = 1e-6;
|
|
|
|
/// The typed absolute residual tolerance of the inverse conversion
|
|
/// ([`TempoMap::wallclock_to_musical`]): a [`Tolerance`] of class
|
|
/// [`ToleranceClass::TempoIntegration`] (Appendix D §"Tolerance Classes" — no
|
|
/// ad-hoc epsilons), absolute bound `INVERSION_TOLERANCE_WHOLE_NOTES` whole
|
|
/// notes, no relative bound, governing validation (it decides whether a
|
|
/// continued-fraction candidate is accepted; conversion is advisory, never
|
|
/// canonical state).
|
|
pub fn inversion_tolerance() -> Tolerance {
|
|
Tolerance::absolute(
|
|
ToleranceClass::TempoIntegration,
|
|
INVERSION_TOLERANCE_WHOLE_NOTES,
|
|
ToleranceGovernance::Validation,
|
|
)
|
|
.expect("constant inversion tolerance is finite and non-negative")
|
|
}
|
|
|
|
/// Relative tolerance below which two segment speeds count as *equal*, so the
|
|
/// integration takes the numerically-stable constant-speed limit instead of the
|
|
/// general logarithmic form (which loses precision via catastrophic cancellation
|
|
/// as the speeds converge). Expressed as a named [`Tolerance`] of class
|
|
/// [`ToleranceClass::TempoIntegration`] per Appendix D §"Tolerance Classes" (no
|
|
/// ad-hoc epsilons); the magnitude is far below any musical tempo distinction.
|
|
const TEMPO_SPEED_DEGENERACY_RELATIVE: f64 = 1e-12;
|
|
|
|
/// The typed degeneracy tolerance used by [`SpeedModel`] to decide whether two
|
|
/// speeds are equal for integration purposes (see
|
|
/// [`TEMPO_SPEED_DEGENERACY_RELATIVE`]). `within(s1, s0)` is non-finite-safe and
|
|
/// relative to `s0`, replacing the former ad-hoc `f64::EPSILON` guards.
|
|
fn speed_degeneracy_tolerance() -> Tolerance {
|
|
Tolerance::absolute(
|
|
ToleranceClass::TempoIntegration,
|
|
0.0,
|
|
ToleranceGovernance::Validation,
|
|
)
|
|
.and_then(|t| t.with_relative(TEMPO_SPEED_DEGENERACY_RELATIVE))
|
|
.expect("constant degeneracy tolerance is finite and non-negative")
|
|
}
|
|
|
|
/// The closed-form integration model of one stretch of musical time: a
|
|
/// half-open interval `[start, end)` in whole notes (`end == None` is open) plus
|
|
/// how the speed (whole notes per second) behaves across it. Built from the
|
|
/// resolved segments, the gaps between them, and the `initial`/leading and
|
|
/// trailing regions (Chapter 3 §"Tempo and the Tempo Map", gap rule).
|
|
struct TempoPiece {
|
|
start: f64,
|
|
end: Option<f64>,
|
|
model: SpeedModel,
|
|
}
|
|
|
|
/// How speed varies across a [`TempoPiece`]. Speeds are whole notes per second,
|
|
/// always strictly positive.
|
|
enum SpeedModel {
|
|
/// Constant speed.
|
|
Const(f64),
|
|
/// Linear interpolation of speed from `s0` (at the piece start) to `s1` (at
|
|
/// the piece end). Equivalent to linear bpm interpolation when the two
|
|
/// tempos share a beat unit (Chapter 3: "Linear interpolation from
|
|
/// start_tempo to end_tempo").
|
|
Linear { s0: f64, s1: f64 },
|
|
/// Exponential (continuous-rate) interpolation of speed from `s0` to `s1`.
|
|
Exponential { s0: f64, s1: f64 },
|
|
}
|
|
|
|
impl SpeedModel {
|
|
/// Wall-clock seconds elapsed across the sub-interval `[u0, u1]` of the
|
|
/// piece, where `u` is the fractional position within the piece (`0` at the
|
|
/// piece start, `1` at its end) and `len` is the piece's musical length in
|
|
/// whole notes. Closed-form per shape.
|
|
fn seconds(&self, u0: f64, u1: f64, len: f64) -> f64 {
|
|
match self {
|
|
SpeedModel::Const(s) => len * (u1 - u0) / s,
|
|
SpeedModel::Linear { s0, s1 } => {
|
|
let d = s1 - s0;
|
|
if speed_degeneracy_tolerance().within(*s1, *s0) {
|
|
len * (u1 - u0) / s0
|
|
} else {
|
|
// ∫ du/(s0 + d·u) = (1/d) ln(s(u)); seconds scale by `len`.
|
|
let su1 = s0 + d * u1;
|
|
let su0 = s0 + d * u0;
|
|
len / d * (su1 / su0).ln()
|
|
}
|
|
}
|
|
SpeedModel::Exponential { s0, s1 } => {
|
|
let l = (s1 / s0).ln();
|
|
if speed_degeneracy_tolerance().within(*s1, *s0) {
|
|
len * (u1 - u0) / s0
|
|
} else {
|
|
// s(u) = s0·e^{l·u}; ∫ du/s(u) = (e^{-l·u0} - e^{-l·u1})/(s0·l).
|
|
len * ((-l * u0).exp() - (-l * u1).exp()) / (s0 * l)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The inverse of [`SpeedModel::seconds`] from `u0 = 0`: the fractional
|
|
/// position `u` within the piece reached after `secs` wall-clock seconds.
|
|
fn invert(&self, secs: f64, len: f64) -> f64 {
|
|
match self {
|
|
SpeedModel::Const(s) => secs * s / len,
|
|
SpeedModel::Linear { s0, s1 } => {
|
|
let d = s1 - s0;
|
|
if speed_degeneracy_tolerance().within(*s1, *s0) {
|
|
secs * s0 / len
|
|
} else {
|
|
// secs = len/d · ln(s(u)/s0) ⇒ s(u) = s0·e^{secs·d/len}.
|
|
let su = s0 * (secs * d / len).exp();
|
|
(su - s0) / d
|
|
}
|
|
}
|
|
SpeedModel::Exponential { s0, s1 } => {
|
|
let l = (s1 / s0).ln();
|
|
if speed_degeneracy_tolerance().within(*s1, *s0) {
|
|
secs * s0 / len
|
|
} else {
|
|
// secs = len/(s0·l)·(1 - e^{-l·u}) ⇒ u = -ln(1 - secs·s0·l/len)/l.
|
|
-(1.0 - secs * s0 * l / len).ln() / l
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The self-contained segment-anchor resolver used by the no-argument
|
|
/// conversions: it places a `Region`-start-relative anchor (the natural
|
|
/// anchoring for a region-local tempo map, where the region origin is musical
|
|
/// zero) and declines everything else (which needs the score graph). Returning
|
|
/// `None` makes the conversion report
|
|
/// [`TempoError::PiecewiseIntegrationUnsupported`] rather than guess.
|
|
fn region_relative_resolve(anchor: &TimeAnchor) -> Option<MusicalPosition> {
|
|
match anchor {
|
|
TimeAnchor::Region {
|
|
edge: RegionEdge::Start,
|
|
offset,
|
|
..
|
|
} => match offset {
|
|
crate::time::AnchorOffset::Zero => Some(MusicalPosition::origin()),
|
|
crate::time::AnchorOffset::Musical(d) => Some(MusicalPosition(d.rational().clone())),
|
|
crate::time::AnchorOffset::WallClock(_) => None,
|
|
},
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
impl TempoMap {
|
|
/// A constant-tempo map (the common case): `initial` tempo, no segments.
|
|
pub fn constant(tempo: Tempo) -> Self {
|
|
TempoMap {
|
|
initial: Some(tempo),
|
|
segments: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Converts a musical position to wall-clock time (Chapter 3 §"Conversion"),
|
|
/// using `region_relative_resolve` for segment boundaries. Exact for a
|
|
/// constant map; integrates closed-form over `Constant`/`Linear`/
|
|
/// `Exponential` segments; reports a [`TempoError`] (never a wrong answer)
|
|
/// for curves or unresolvable boundaries. Result rounds to the nearest
|
|
/// nanosecond.
|
|
pub fn musical_to_wallclock(&self, pos: &MusicalPosition) -> Result<WallClockTime, TempoError> {
|
|
self.musical_to_wallclock_with(pos, region_relative_resolve)
|
|
}
|
|
|
|
/// As [`TempoMap::musical_to_wallclock`], but resolves segment-boundary
|
|
/// [`TimeAnchor`]s through `resolve` (e.g. against the score graph), so
|
|
/// event- and measure-anchored segments can be placed too.
|
|
pub fn musical_to_wallclock_with(
|
|
&self,
|
|
pos: &MusicalPosition,
|
|
resolve: impl Fn(&TimeAnchor) -> Option<MusicalPosition>,
|
|
) -> Result<WallClockTime, TempoError> {
|
|
let pieces = self.build_pieces(&resolve)?;
|
|
let target = pos.rational().to_f64();
|
|
let seconds = elapsed_seconds(&pieces, target)?;
|
|
let ns = (seconds * 1e9).round();
|
|
checked_i64(ns).map(WallClockTime)
|
|
}
|
|
|
|
/// Converts a wall-clock time to a musical position (Chapter 3
|
|
/// §"Conversion"), using `region_relative_resolve` for segment boundaries.
|
|
/// The inverse uses a deterministic continued-fraction rational
|
|
/// approximation with the documented bounds [`INVERSION_MAX_ITERATIONS`] /
|
|
/// [`INVERSION_MAX_DENOMINATOR`] / [`inversion_tolerance`], so an
|
|
/// ordinary rhythm (a triplet `1/12`, a dotted `3/8`) round-trips exactly
|
|
/// rather than being quantized to a fixed grid.
|
|
pub fn wallclock_to_musical(&self, time: WallClockTime) -> Result<MusicalPosition, TempoError> {
|
|
self.wallclock_to_musical_with(time, region_relative_resolve)
|
|
}
|
|
|
|
/// As [`TempoMap::wallclock_to_musical`], but resolves segment-boundary
|
|
/// [`TimeAnchor`]s through `resolve`.
|
|
pub fn wallclock_to_musical_with(
|
|
&self,
|
|
time: WallClockTime,
|
|
resolve: impl Fn(&TimeAnchor) -> Option<MusicalPosition>,
|
|
) -> Result<MusicalPosition, TempoError> {
|
|
let pieces = self.build_pieces(&resolve)?;
|
|
let seconds = time.0 as f64 / 1e9;
|
|
let whole_notes = whole_notes_at(&pieces, seconds)?;
|
|
rational_from_f64(
|
|
whole_notes,
|
|
INVERSION_MAX_DENOMINATOR,
|
|
inversion_tolerance().absolute.get(),
|
|
)
|
|
.map(MusicalPosition)
|
|
.ok_or(TempoError::ConversionOverflow)
|
|
}
|
|
|
|
/// Resolves and validates the segments, then builds the contiguous list of
|
|
/// [`TempoPiece`]s covering musical time from zero. Returns a
|
|
/// [`TempoError`] for an empty map, an unresolvable boundary, a curve
|
|
/// segment, or a malformed segment sequence.
|
|
fn build_pieces(
|
|
&self,
|
|
resolve: &impl Fn(&TimeAnchor) -> Option<MusicalPosition>,
|
|
) -> Result<Vec<TempoPiece>, TempoError> {
|
|
// The constant common case: one open piece at `initial`.
|
|
if self.segments.is_empty() {
|
|
let t = self.initial.as_ref().ok_or(TempoError::NoTempo)?;
|
|
return Ok(vec![TempoPiece {
|
|
start: 0.0,
|
|
end: None,
|
|
model: SpeedModel::Const(t.whole_notes_per_second()),
|
|
}]);
|
|
}
|
|
|
|
// Resolve every segment's start (required) to a musical whole-note
|
|
// value; the end is resolved if present, else taken lazily as the next
|
|
// segment's start (or open for the final segment).
|
|
let mut starts: Vec<f64> = Vec::with_capacity(self.segments.len());
|
|
for seg in &self.segments {
|
|
let p = resolve(&seg.start).ok_or(TempoError::PiecewiseIntegrationUnsupported)?;
|
|
starts.push(p.rational().to_f64());
|
|
}
|
|
// Segments MUST be in monotonically increasing start order (Chapter 3).
|
|
for w in starts.windows(2) {
|
|
if w[1] < w[0] {
|
|
return Err(TempoError::MalformedTempoMap);
|
|
}
|
|
}
|
|
|
|
let n = self.segments.len();
|
|
let mut pieces: Vec<TempoPiece> = Vec::new();
|
|
// Leading region before the first segment: the gap rule gives it
|
|
// `initial` (or, absent that, the first segment's start_tempo).
|
|
let first_start = starts[0];
|
|
if first_start > 0.0 {
|
|
let lead = self
|
|
.initial
|
|
.as_ref()
|
|
.unwrap_or(&self.segments[0].start_tempo);
|
|
pieces.push(TempoPiece {
|
|
start: 0.0,
|
|
end: Some(first_start),
|
|
model: SpeedModel::Const(lead.whole_notes_per_second()),
|
|
});
|
|
}
|
|
|
|
for (i, seg) in self.segments.iter().enumerate() {
|
|
let start = starts[i];
|
|
// Effective end: the segment's own end if given, else the next
|
|
// segment's start, else open.
|
|
let explicit_end = match &seg.end {
|
|
Some(a) => Some(
|
|
resolve(a)
|
|
.ok_or(TempoError::PiecewiseIntegrationUnsupported)?
|
|
.rational()
|
|
.to_f64(),
|
|
),
|
|
None => None,
|
|
};
|
|
let next_start = (i + 1 < n).then(|| starts[i + 1]);
|
|
let eff_end = explicit_end.or(next_start);
|
|
if let Some(e) = eff_end {
|
|
if e < start {
|
|
return Err(TempoError::MalformedTempoMap);
|
|
}
|
|
// Non-overlap with the next segment.
|
|
if let Some(ns) = next_start {
|
|
if e > ns {
|
|
return Err(TempoError::MalformedTempoMap);
|
|
}
|
|
}
|
|
}
|
|
let s0 = seg.start_tempo.whole_notes_per_second();
|
|
let model = match seg.shape {
|
|
TempoShape::Curve => return Err(TempoError::CurveIntegrationUnsupported),
|
|
TempoShape::Constant => {
|
|
// A Constant segment's end_tempo MUST be absent or equal to
|
|
// start_tempo (Chapter 3).
|
|
if let Some(et) = &seg.end_tempo {
|
|
if et != &seg.start_tempo {
|
|
return Err(TempoError::MalformedTempoMap);
|
|
}
|
|
}
|
|
SpeedModel::Const(s0)
|
|
}
|
|
TempoShape::Linear | TempoShape::Exponential => {
|
|
// A non-Constant segment needs a finite end and an end_tempo.
|
|
let et = seg
|
|
.end_tempo
|
|
.as_ref()
|
|
.ok_or(TempoError::MalformedTempoMap)?;
|
|
if eff_end.is_none() {
|
|
return Err(TempoError::MalformedTempoMap);
|
|
}
|
|
let s1 = et.whole_notes_per_second();
|
|
if seg.shape == TempoShape::Linear {
|
|
SpeedModel::Linear { s0, s1 }
|
|
} else {
|
|
SpeedModel::Exponential { s0, s1 }
|
|
}
|
|
}
|
|
};
|
|
pieces.push(TempoPiece {
|
|
start,
|
|
end: eff_end,
|
|
model,
|
|
});
|
|
|
|
// Gap between this segment's end and the next segment's start: the
|
|
// gap rule holds the most-recent terminating tempo (Chapter 3).
|
|
if let (Some(e), Some(ns)) = (eff_end, next_start) {
|
|
if e < ns {
|
|
let term = seg.end_tempo.as_ref().unwrap_or(&seg.start_tempo);
|
|
pieces.push(TempoPiece {
|
|
start: e,
|
|
end: Some(ns),
|
|
model: SpeedModel::Const(term.whole_notes_per_second()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trailing region after a final segment with a finite end: the gap rule
|
|
// holds its terminating tempo to infinity.
|
|
if let Some(last) = self.segments.last() {
|
|
if let Some(TempoPiece { end: Some(e), .. }) = pieces.last() {
|
|
let e = *e;
|
|
let term = last.end_tempo.as_ref().unwrap_or(&last.start_tempo);
|
|
pieces.push(TempoPiece {
|
|
start: e,
|
|
end: None,
|
|
model: SpeedModel::Const(term.whole_notes_per_second()),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(pieces)
|
|
}
|
|
}
|
|
|
|
/// Integrates the pieces to find the wall-clock seconds elapsed from musical
|
|
/// zero to `target` whole notes.
|
|
fn elapsed_seconds(pieces: &[TempoPiece], target: f64) -> Result<f64, TempoError> {
|
|
if !target.is_finite() {
|
|
return Err(TempoError::ConversionOverflow);
|
|
}
|
|
let mut total = 0.0;
|
|
for p in pieces {
|
|
if target <= p.start {
|
|
break;
|
|
}
|
|
let piece_end = p.end.unwrap_or(target).min(target);
|
|
if piece_end <= p.start {
|
|
continue;
|
|
}
|
|
let len = match p.end {
|
|
// Length used to normalise `u`; an open piece is constant, so its
|
|
// length cancels — use the covered span itself.
|
|
Some(e) => e - p.start,
|
|
None => piece_end - p.start,
|
|
};
|
|
if len <= 0.0 {
|
|
continue;
|
|
}
|
|
let u0 = 0.0;
|
|
let u1 = (piece_end - p.start) / len;
|
|
total += p.model.seconds(u0, u1, len);
|
|
}
|
|
if total.is_finite() {
|
|
Ok(total)
|
|
} else {
|
|
Err(TempoError::ConversionOverflow)
|
|
}
|
|
}
|
|
|
|
/// Inverts [`elapsed_seconds`]: the musical whole-note position reached after
|
|
/// `target_secs` wall-clock seconds from musical zero.
|
|
fn whole_notes_at(pieces: &[TempoPiece], target_secs: f64) -> Result<f64, TempoError> {
|
|
if !target_secs.is_finite() {
|
|
return Err(TempoError::ConversionOverflow);
|
|
}
|
|
if target_secs <= 0.0 {
|
|
return Ok(0.0);
|
|
}
|
|
let mut acc = 0.0;
|
|
for p in pieces {
|
|
match p.end {
|
|
Some(e) => {
|
|
let len = e - p.start;
|
|
if len <= 0.0 {
|
|
continue;
|
|
}
|
|
let full = p.model.seconds(0.0, 1.0, len);
|
|
if acc + full >= target_secs {
|
|
let u = p.model.invert(target_secs - acc, len);
|
|
return Ok(p.start + u * len);
|
|
}
|
|
acc += full;
|
|
}
|
|
None => {
|
|
// Open final piece (always Const): solve directly.
|
|
let remaining = target_secs - acc;
|
|
// Use a unit length so `u` maps straight to whole notes.
|
|
let u = p.model.invert(remaining, 1.0);
|
|
return Ok(p.start + u);
|
|
}
|
|
}
|
|
}
|
|
// No open tail (should not happen — build_pieces always ends open); clamp to
|
|
// the final piece end as a sound fallback.
|
|
Ok(pieces.last().and_then(|p| p.end).unwrap_or(0.0))
|
|
}
|
|
|
|
/// Rounds an `f64` nanosecond/whole-note quantity to `i64`, returning
|
|
/// [`TempoError::ConversionOverflow`] for a non-finite or out-of-range value
|
|
/// instead of the saturating `as i64` cast (Appendix D §"Floating-Point Values").
|
|
fn checked_i64(value: f64) -> Result<i64, TempoError> {
|
|
if value.is_finite() && value >= i64::MIN as f64 && value <= i64::MAX as f64 {
|
|
Ok(value as i64)
|
|
} else {
|
|
Err(TempoError::ConversionOverflow)
|
|
}
|
|
}
|
|
|
|
/// The deterministic continued-fraction rational approximation behind
|
|
/// [`TempoMap::wallclock_to_musical`]: the simplest `numerator/denominator`
|
|
/// (denominator ≤ `max_den`) within `tol` whole notes of `x`, or the best
|
|
/// convergent reached within [`INVERSION_MAX_ITERATIONS`]. Returns `None` only
|
|
/// if the result does not fit the inline rational range.
|
|
fn rational_from_f64(x: f64, max_den: u64, tol: f64) -> Option<RationalTime> {
|
|
if !x.is_finite() {
|
|
return None;
|
|
}
|
|
let neg = x < 0.0;
|
|
let x_abs = x.abs();
|
|
// Convergent recurrence h/k, seeded with h_{-1}=1,h_{-2}=0 / k_{-1}=0,k_{-2}=1.
|
|
let (mut h_prev2, mut h_prev1) = (0i128, 1i128);
|
|
let (mut k_prev2, mut k_prev1) = (1i128, 0i128);
|
|
let mut value = x_abs;
|
|
let mut best = (0i128, 1i128); // 0/1 until the first convergent lands.
|
|
for _ in 0..INVERSION_MAX_ITERATIONS {
|
|
let a = value.floor();
|
|
if !a.is_finite() {
|
|
break;
|
|
}
|
|
let ai = a as i128;
|
|
// Checked convergent recurrence: a pathological `ai` must never wrap i128
|
|
// silently (which would yield a wrong rational that still fits the final
|
|
// range check). On overflow, keep the last good convergent.
|
|
let Some(h) = ai.checked_mul(h_prev1).and_then(|p| p.checked_add(h_prev2)) else {
|
|
break;
|
|
};
|
|
let Some(k) = ai.checked_mul(k_prev1).and_then(|p| p.checked_add(k_prev2)) else {
|
|
break;
|
|
};
|
|
if k <= 0 || (k as u128) > max_den as u128 {
|
|
break;
|
|
}
|
|
h_prev2 = h_prev1;
|
|
h_prev1 = h;
|
|
k_prev2 = k_prev1;
|
|
k_prev1 = k;
|
|
best = (h, k);
|
|
if (h as f64 / k as f64 - x_abs).abs() <= tol {
|
|
break;
|
|
}
|
|
let frac = value - a;
|
|
// Stop once the residual fraction is too small to introduce another
|
|
// convergent within `max_den` (a meaningful bound, not an ad-hoc epsilon);
|
|
// this also keeps the next `value = 1/frac`, hence `ai`, bounded.
|
|
if frac <= 1.0 / max_den as f64 {
|
|
break;
|
|
}
|
|
value = 1.0 / frac;
|
|
}
|
|
let (num, den) = best;
|
|
let num = if neg { -num } else { num };
|
|
let n = i64::try_from(num).ok()?;
|
|
let d = i64::try_from(den).ok()?;
|
|
RationalTime::new(n, d)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ids::{RegionId, ReplicaId};
|
|
use crate::time::{AnchorOffset, RationalTime};
|
|
|
|
/// A `Region`-start anchor offset by `whole_notes` of musical time — the
|
|
/// natural, self-contained anchoring for a region-local tempo segment.
|
|
fn region_at(whole_notes: RationalTime) -> TimeAnchor {
|
|
TimeAnchor::Region {
|
|
id: RegionId::new(ReplicaId(1), 0),
|
|
edge: RegionEdge::Start,
|
|
offset: AnchorOffset::Musical(MusicalDuration(whole_notes)),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn constant_tempo_round_trips_musical_and_wallclock() {
|
|
// 120 quarter-note BPM: a quarter note = 0.5 s, a whole note = 2 s.
|
|
let map = TempoMap::constant(Tempo::quarter(120.0).unwrap());
|
|
let one_whole = MusicalPosition(RationalTime::from_int(1));
|
|
let t = map.musical_to_wallclock(&one_whole).unwrap();
|
|
assert_eq!(t, WallClockTime(2_000_000_000)); // 2 s in ns
|
|
// And back.
|
|
assert_eq!(map.wallclock_to_musical(t).unwrap(), one_whole);
|
|
// Half note at 120 q-bpm = 1 s.
|
|
let half = MusicalPosition(RationalTime::new(1, 2).unwrap());
|
|
assert_eq!(
|
|
map.musical_to_wallclock(&half).unwrap(),
|
|
WallClockTime(1_000_000_000)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_rhythms_round_trip_exactly() {
|
|
// The 1/1024-grid quantization used to lose a triplet 1/12; the
|
|
// continued-fraction inverse recovers it (and other ordinary rhythms).
|
|
let map = TempoMap::constant(Tempo::quarter(120.0).unwrap());
|
|
for (n, d) in [(1, 12), (1, 3), (3, 8), (5, 6), (7, 16), (1, 7)] {
|
|
let pos = MusicalPosition(RationalTime::new(n, d).unwrap());
|
|
let t = map.musical_to_wallclock(&pos).unwrap();
|
|
assert_eq!(
|
|
map.wallclock_to_musical(t).unwrap(),
|
|
pos,
|
|
"rhythm {n}/{d} did not round-trip"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tempo_rejects_bad_values() {
|
|
assert!(Tempo::quarter(0.0).is_none());
|
|
assert!(Tempo::quarter(-60.0).is_none());
|
|
assert!(Tempo::quarter(f64::NAN).is_none());
|
|
assert!(Tempo::new(120.0, MusicalDuration::zero()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn inversion_tolerance_is_a_typed_tempo_integration_tolerance() {
|
|
// The inverse's residual bound is a named `Tolerance` of class
|
|
// `TempoIntegration` (Appendix D §"Tolerance Classes": no ad-hoc epsilon
|
|
// constants), pinned to the documented magnitude: absolute 1e-6 whole
|
|
// notes, no relative bound, validation governance.
|
|
let t = inversion_tolerance();
|
|
assert_eq!(t.class, ToleranceClass::TempoIntegration);
|
|
assert_eq!(t.absolute.get(), 1e-6);
|
|
assert_eq!(t.relative, None);
|
|
assert_eq!(t.governance, ToleranceGovernance::Validation);
|
|
}
|
|
|
|
#[test]
|
|
fn linear_segment_is_integrated_not_rejected() {
|
|
// A single linear ramp over [0, 1] whole notes from 60 to 120 q-bpm.
|
|
// Speeds: 0.25 -> 0.5 whole notes/s. Closed form for one whole note is
|
|
// (1/Δs)·ln(s1/s0) = (1/0.25)·ln(2) = 4·ln 2 ≈ 2.77259 s.
|
|
let map = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: Some(region_at(RationalTime::from_int(1))),
|
|
start_tempo: Tempo::quarter(60.0).unwrap(),
|
|
end_tempo: Some(Tempo::quarter(120.0).unwrap()),
|
|
shape: TempoShape::Linear,
|
|
}],
|
|
};
|
|
let one = MusicalPosition(RationalTime::from_int(1));
|
|
let t = map
|
|
.musical_to_wallclock(&one)
|
|
.expect("linear is implemented");
|
|
let secs = t.0 as f64 / 1e9;
|
|
assert!((secs - 4.0 * 2f64.ln()).abs() < 1e-6, "got {secs}s");
|
|
// The ramp inverts and ordinary positions round-trip.
|
|
assert_eq!(map.wallclock_to_musical(t).unwrap(), one);
|
|
let half = MusicalPosition(RationalTime::new(1, 2).unwrap());
|
|
let th = map.musical_to_wallclock(&half).unwrap();
|
|
assert_eq!(map.wallclock_to_musical(th).unwrap(), half);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_endpoint_linear_segment_uses_the_constant_limit() {
|
|
// start_tempo == end_tempo: the typed speed-degeneracy tolerance must
|
|
// select the constant-speed limit, integrating identically to a constant
|
|
// tempo (no catastrophic cancellation, no NaN from the general ln-form).
|
|
let map = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: Some(region_at(RationalTime::from_int(1))),
|
|
start_tempo: Tempo::quarter(120.0).unwrap(),
|
|
end_tempo: Some(Tempo::quarter(120.0).unwrap()),
|
|
shape: TempoShape::Linear,
|
|
}],
|
|
};
|
|
let one = MusicalPosition(RationalTime::from_int(1));
|
|
let t = map.musical_to_wallclock(&one).unwrap();
|
|
// 120 q-bpm constant => one whole note = 2 s.
|
|
assert_eq!(t, WallClockTime(2_000_000_000));
|
|
assert_eq!(map.wallclock_to_musical(t).unwrap(), one);
|
|
}
|
|
|
|
#[test]
|
|
fn inversion_handles_extreme_inputs_without_overflow() {
|
|
// Pathological wall-clock times must not panic or wrap the continued-
|
|
// fraction convergent recurrence; each resolves to a finite musical
|
|
// position or a clean error, never UB.
|
|
let map = TempoMap::constant(Tempo::quarter(120.0).unwrap());
|
|
for t in [i64::MAX, i64::MIN, i64::MAX - 1, 1_000_000_000_000_000] {
|
|
let _ = map.wallclock_to_musical(WallClockTime(t));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exponential_segment_is_integrated() {
|
|
let map = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: Some(region_at(RationalTime::from_int(2))),
|
|
start_tempo: Tempo::quarter(60.0).unwrap(),
|
|
end_tempo: Some(Tempo::quarter(120.0).unwrap()),
|
|
shape: TempoShape::Exponential,
|
|
}],
|
|
};
|
|
let p = MusicalPosition(RationalTime::from_int(1));
|
|
let t = map
|
|
.musical_to_wallclock(&p)
|
|
.expect("exponential is implemented");
|
|
// Round-trips through the closed-form inverse.
|
|
assert_eq!(map.wallclock_to_musical(t).unwrap(), p);
|
|
}
|
|
|
|
#[test]
|
|
fn deferred_and_malformed_cases_report_errors_not_wrong_answers() {
|
|
let empty = TempoMap::default();
|
|
assert_eq!(
|
|
empty.musical_to_wallclock(&MusicalPosition(RationalTime::from_int(1))),
|
|
Err(TempoError::NoTempo)
|
|
);
|
|
|
|
// A curve segment (resolvable boundary) defers to the numerical algorithm.
|
|
let curved = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: Some(region_at(RationalTime::from_int(1))),
|
|
start_tempo: Tempo::quarter(120.0).unwrap(),
|
|
end_tempo: Some(Tempo::quarter(60.0).unwrap()),
|
|
shape: TempoShape::Curve,
|
|
}],
|
|
};
|
|
assert_eq!(
|
|
curved.musical_to_wallclock(&MusicalPosition(RationalTime::from_int(1))),
|
|
Err(TempoError::CurveIntegrationUnsupported)
|
|
);
|
|
|
|
// A wall-clock-anchored boundary cannot be placed by the self-contained
|
|
// resolver, so the conversion declines rather than guesses.
|
|
let unplaceable = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: TimeAnchor::WallClock {
|
|
time: WallClockTime(0),
|
|
},
|
|
end: None,
|
|
start_tempo: Tempo::quarter(120.0).unwrap(),
|
|
end_tempo: None,
|
|
shape: TempoShape::Constant,
|
|
}],
|
|
};
|
|
assert_eq!(
|
|
unplaceable.musical_to_wallclock(&MusicalPosition(RationalTime::from_int(1))),
|
|
Err(TempoError::PiecewiseIntegrationUnsupported)
|
|
);
|
|
|
|
// A non-Constant segment without an end_tempo is malformed.
|
|
let no_end_tempo = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: Some(region_at(RationalTime::from_int(1))),
|
|
start_tempo: Tempo::quarter(60.0).unwrap(),
|
|
end_tempo: None,
|
|
shape: TempoShape::Linear,
|
|
}],
|
|
};
|
|
assert_eq!(
|
|
no_end_tempo.musical_to_wallclock(&MusicalPosition(RationalTime::from_int(1))),
|
|
Err(TempoError::MalformedTempoMap)
|
|
);
|
|
|
|
// A Constant segment whose end_tempo disagrees with start_tempo is
|
|
// malformed.
|
|
let bad_constant = TempoMap {
|
|
initial: None,
|
|
segments: vec![TempoSegment {
|
|
start: region_at(RationalTime::zero()),
|
|
end: None,
|
|
start_tempo: Tempo::quarter(60.0).unwrap(),
|
|
end_tempo: Some(Tempo::quarter(120.0).unwrap()),
|
|
shape: TempoShape::Constant,
|
|
}],
|
|
};
|
|
assert_eq!(
|
|
bad_constant.musical_to_wallclock(&MusicalPosition(RationalTime::from_int(1))),
|
|
Err(TempoError::MalformedTempoMap)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_conversion_reports_overflow_not_saturation() {
|
|
let map = TempoMap::constant(Tempo::quarter(120.0).unwrap());
|
|
let huge = MusicalPosition(RationalTime::new(i64::MAX, 1).unwrap());
|
|
assert_eq!(
|
|
map.musical_to_wallclock(&huge),
|
|
Err(TempoError::ConversionOverflow)
|
|
);
|
|
}
|
|
}
|