epiphany/crates/epiphany-core/src/tempo.rs

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)
);
}
}