Skip to main content

tono_core/
units.rs

1//! Typed units and exact musical time for the composition/compile layer
2//! (ADR 0002).
3//!
4//! The composition model speaks in exact [`Beat`] rationals; audio speaks in
5//! integer [`Frames`]. The two meet exactly once, at the scheduling boundary,
6//! through [`beat_to_frames`] — every placement lands on the same frame on
7//! every platform because the rounding rule is specified, not emergent. The
8//! plain newtypes ([`Samples`], [`SampleRate`], [`Hertz`], [`Decibels`],
9//! [`Tempo`], [`Bars`]) exist so a function's signature says which quantity it
10//! takes instead of trusting a bare number at every call site.
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15/// A count of audio frames (one sample per channel) — the engine's unit of
16/// position and length on the audio timeline.
17#[derive(
18    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
19)]
20#[serde(transparent)]
21pub struct Frames(pub u64);
22
23/// A count of individual samples (channel-agnostic), e.g. a buffer length.
24#[derive(
25    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
26)]
27#[serde(transparent)]
28pub struct Samples(pub u64);
29
30/// A sample rate in Hz (frames per second), e.g. 44100 or 48000.
31#[derive(
32    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
33)]
34#[serde(transparent)]
35pub struct SampleRate(pub u32);
36
37/// A frequency in Hz.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
39#[serde(transparent)]
40pub struct Hertz(pub f32);
41
42/// A level in decibels.
43#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
44#[serde(transparent)]
45pub struct Decibels(pub f32);
46
47/// A tempo in beats per minute. Below 1 BPM the conversion to frames floors
48/// the tempo at 1 — the same clamp the song compiler applies
49/// (`Song::to_doc`), so a degenerate tempo can't produce absurd frame counts.
50#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(transparent)]
52pub struct Tempo(pub f32);
53
54/// A count of bars (measures) — the arrangement's coarse unit of position
55/// and length.
56#[derive(
57    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
58)]
59#[serde(transparent)]
60pub struct Bars(pub u32);
61
62impl From<u64> for Frames {
63    fn from(n: u64) -> Self {
64        Frames(n)
65    }
66}
67
68impl From<u64> for Samples {
69    fn from(n: u64) -> Self {
70        Samples(n)
71    }
72}
73
74impl std::ops::Add for Frames {
75    type Output = Frames;
76    fn add(self, rhs: Frames) -> Frames {
77        Frames(self.0 + rhs.0)
78    }
79}
80
81impl std::ops::Sub for Frames {
82    type Output = Frames;
83    fn sub(self, rhs: Frames) -> Frames {
84        Frames(self.0 - rhs.0)
85    }
86}
87
88impl std::ops::Add for Samples {
89    type Output = Samples;
90    fn add(self, rhs: Samples) -> Samples {
91        Samples(self.0 + rhs.0)
92    }
93}
94
95impl std::ops::Sub for Samples {
96    type Output = Samples;
97    fn sub(self, rhs: Samples) -> Samples {
98        Samples(self.0 - rhs.0)
99    }
100}
101
102impl std::ops::Add for Bars {
103    type Output = Bars;
104    fn add(self, rhs: Bars) -> Bars {
105        Bars(self.0 + rhs.0)
106    }
107}
108
109impl std::ops::Sub for Bars {
110    type Output = Bars;
111    fn sub(self, rhs: Bars) -> Bars {
112        Bars(self.0 - rhs.0)
113    }
114}
115
116/// An exact musical position or duration as a rational number of beats:
117/// `num / den`, always normalized (denominator positive, gcd-reduced, zero
118/// canonicalized to `0/1`). Tuplets and repeated transforms (stretch, rotate,
119/// concatenate) stay exact — no floating-point drift ever accumulates before
120/// the frame boundary.
121///
122/// `Beat::new(2, 4)` IS `Beat::new(1, 2)`. A zero `den` is floored to 1 (the
123/// same degenerate-value clamp the song grid applies), so deserialization can
124/// never produce a division by zero. Serde is the flat struct `{"num":..,"den":..}`;
125/// deserializing normalizes through [`Beat::new`].
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, JsonSchema)]
127pub struct Beat {
128    /// The numerator (beats × `den`), carries the sign.
129    pub num: i64,
130    /// The denominator, always > 0 after normalization.
131    pub den: u32,
132}
133
134impl Beat {
135    /// A normalized beat: gcd-reduced, `den > 0`, zero as `0/1`. A `den` of 0
136    /// is floored to 1.
137    pub const fn new(num: i64, den: u32) -> Beat {
138        let den = if den == 0 { 1 } else { den };
139        if num == 0 {
140            return Beat { num: 0, den: 1 };
141        }
142        // Euclid on |num| and den; the gcd fits i64 because it divides den
143        // (< 2^32), so `num / g` can never overflow — not even i64::MIN.
144        let mut a = num.unsigned_abs();
145        let mut b = den as u64;
146        while b != 0 {
147            let t = a % b;
148            a = b;
149            b = t;
150        }
151        let g = a;
152        Beat {
153            num: num / g as i64,
154            den: (den as u64 / g) as u32,
155        }
156    }
157
158    /// Zero beats — the origin of the musical timeline.
159    pub const fn zero() -> Beat {
160        Beat { num: 0, den: 1 }
161    }
162
163    /// A whole number of beats (`n/1`).
164    pub const fn from_int(n: i64) -> Beat {
165        Beat { num: n, den: 1 }
166    }
167
168    /// Exact sum, erroring when the (unreduced) result doesn't fit a `Beat`.
169    pub fn checked_add(self, other: Beat) -> Result<Beat, BeatError> {
170        let num = self.num as i128 * other.den as i128 + other.num as i128 * self.den as i128;
171        let den = self.den as i128 * other.den as i128;
172        checked(num, den)
173    }
174
175    /// Exact difference, erroring when the (unreduced) result doesn't fit a
176    /// `Beat`.
177    pub fn checked_sub(self, other: Beat) -> Result<Beat, BeatError> {
178        let num = self.num as i128 * other.den as i128 - other.num as i128 * self.den as i128;
179        let den = self.den as i128 * other.den as i128;
180        checked(num, den)
181    }
182
183    /// Exact scaling by the rational factor `num / den`, erroring when the
184    /// (unreduced) result doesn't fit a `Beat`.
185    pub fn mul_rational(self, num: i64, den: u32) -> Result<Beat, BeatError> {
186        checked(
187            self.num as i128 * num as i128,
188            self.den as i128 * den as i128,
189        )
190    }
191
192    /// Exact scaling by a whole number (e.g. a 4-beat phrase repeated 3 times
193    /// is `phrase.scale(3)`).
194    pub fn scale(self, factor: i64) -> Result<Beat, BeatError> {
195        self.mul_rational(factor, 1)
196    }
197
198    /// The floating-point value, for the single crossing to frames at the
199    /// scheduling boundary (and nowhere else — composition math stays exact).
200    pub fn to_f64(self) -> f64 {
201        self.num as f64 / self.den as f64
202    }
203}
204
205/// Normalize an exact i128 intermediate back into a `Beat`, or report overflow.
206fn checked(num: i128, den: i128) -> Result<Beat, BeatError> {
207    let num = i64::try_from(num).map_err(|_| BeatError::Overflow)?;
208    let den = u32::try_from(den).map_err(|_| BeatError::Overflow)?;
209    Ok(Beat::new(num, den))
210}
211
212impl From<(i64, u32)> for Beat {
213    fn from((num, den): (i64, u32)) -> Beat {
214        Beat::new(num, den)
215    }
216}
217
218impl Ord for Beat {
219    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
220        // Cross-multiply in i128: num/den vs other.num/other.den. Both
221        // denominators are positive, so the products compare the same way;
222        // i128 keeps even i64::MAX × u32::MAX from overflowing.
223        (self.num as i128 * other.den as i128).cmp(&(other.num as i128 * self.den as i128))
224    }
225}
226
227impl PartialOrd for Beat {
228    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
229        Some(self.cmp(other))
230    }
231}
232
233impl std::fmt::Display for Beat {
234    /// `3/2`, or the bare integer when the denominator is 1 (`4`, not `4/1`).
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        if self.den == 1 {
237            write!(f, "{}", self.num)
238        } else {
239            write!(f, "{}/{}", self.num, self.den)
240        }
241    }
242}
243
244/// Deserializing routes through [`Beat::new`] so the normalization invariant
245/// holds no matter where the value came from.
246impl<'de> Deserialize<'de> for Beat {
247    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
248    where
249        D: serde::Deserializer<'de>,
250    {
251        #[derive(Deserialize)]
252        struct Raw {
253            num: i64,
254            den: u32,
255        }
256        let raw = Raw::deserialize(deserializer)?;
257        Ok(Beat::new(raw.num, raw.den))
258    }
259}
260
261/// Why exact beat arithmetic failed.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum BeatError {
264    /// The exact result doesn't fit in a `Beat` (i64 numerator, u32
265    /// denominator) — a pathological value, not a musical one.
266    Overflow,
267}
268
269impl std::fmt::Display for BeatError {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        match self {
272            BeatError::Overflow => f.write_str("beat arithmetic overflow"),
273        }
274    }
275}
276
277impl std::error::Error for BeatError {}
278
279/// Convert an exact beat position to an audio frame — the ONE place musical
280/// time crosses to audio time (ADR 0002). All composition math before this
281/// boundary stays rational, so the conversion never compounds rounding error.
282///
283/// The rule is specified exactly: `seconds = beats × 60 / bpm` in `f64`, then
284/// `frames = round(seconds × rate)`, where `f64::round` rounds halves AWAY
285/// FROM ZERO (a `.5` frame rounds up for positive beats). Every placement
286/// therefore lands on the same frame on every platform and in every process.
287///
288/// Degenerate inputs clamp rather than blow up: a tempo below 1 BPM is
289/// floored at 1 (the same clamp `Song::to_doc` applies), and a negative beat
290/// — a position before the song's start — clamps to frame 0, since `Frames`
291/// can't be negative.
292pub fn beat_to_frames(beat: Beat, tempo: Tempo, rate: SampleRate) -> Frames {
293    let bpm = (tempo.0 as f64).max(1.0);
294    let seconds = beat.to_f64() * 60.0 / bpm;
295    let frames = seconds * rate.0 as f64;
296    Frames(frames.round().max(0.0) as u64)
297}
298
299/// A time-signature change at a bar (0-based), for the meter map: from `bar`
300/// until the next change, a bar is `numerator`/`denominator` long. In the
301/// song's beat grid a bar is `numerator × (4 / denominator)` quarter-note
302/// beats (6/8 = 3, 3/4 = 3, 4/4 = 4). Denominators must be powers of two.
303#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
304pub struct MeterPoint {
305    /// The bar the change takes effect at (0-based). The first point must be
306    /// bar 0 when a meter map is present.
307    pub bar: u32,
308    /// Beats per bar (the time-signature numerator).
309    pub numerator: u32,
310    /// The note value one beat is written in (4 = quarter, 8 = eighth).
311    pub denominator: u32,
312}
313
314/// The meter in effect at `bar` under a meter map (the last point at or
315/// before it; `default_numerator`/4 when the map is empty or starts later).
316pub fn meter_at(map: &[MeterPoint], default_numerator: u32, bar: u32) -> MeterPoint {
317    let fallback = MeterPoint {
318        bar: 0,
319        numerator: default_numerator.max(1),
320        denominator: 4,
321    };
322    map.iter()
323        .rev()
324        .find(|p| p.bar <= bar)
325        .copied()
326        .unwrap_or(fallback)
327}
328
329/// The length of bar `index` in quarter-note beats: `pickup` for bar 0 when
330/// set, otherwise the meter in effect (`numerator × 4/denominator`).
331pub fn bar_len(
332    map: &[MeterPoint],
333    default_numerator: u32,
334    pickup: Option<Beat>,
335    index: u32,
336) -> Beat {
337    if index == 0
338        && let Some(p) = pickup
339    {
340        return p;
341    }
342    let meter = meter_at(map, default_numerator, index);
343    Beat::new(meter.numerator as i64 * 4, meter.denominator)
344}
345
346/// The exact beat `bar` starts at — the pickup plus the meter walk,
347/// segment-wise (the map is short, so a pathological bar costs O(map), not
348/// O(bar)). Saturates rather than wraps at absurd bars.
349pub fn beat_at_bar(
350    map: &[MeterPoint],
351    default_numerator: u32,
352    pickup: Option<Beat>,
353    bar: u32,
354) -> Beat {
355    let mut beats = Beat::zero();
356    if bar == 0 {
357        return beats;
358    }
359    beats = beats
360        .checked_add(bar_len(map, default_numerator, pickup, 0))
361        .unwrap_or(Beat::new(i64::MAX, 1));
362    let mut i = 1u32;
363    while i < bar {
364        let seg_end = map
365            .iter()
366            .map(|p| p.bar)
367            .filter(|b| *b > i)
368            .min()
369            .unwrap_or(u32::MAX)
370            .min(bar);
371        let span = bar_len(map, default_numerator, pickup, i)
372            .scale(i64::from(seg_end - i))
373            .unwrap_or(Beat::new(i64::MAX, 1));
374        beats = beats.checked_add(span).unwrap_or(Beat::new(i64::MAX, 1));
375        i = seg_end;
376    }
377    beats
378}
379
380/// Bars elapsed at `beat` under the meter map (binary search over the
381/// monotonic beat walk; saturates at absurd inputs).
382pub fn bar_count_at_beat(
383    map: &[MeterPoint],
384    default_numerator: u32,
385    pickup: Option<Beat>,
386    beat: Beat,
387) -> u32 {
388    if beat <= Beat::zero() {
389        return 0;
390    }
391    let mut lo = 0u32;
392    let mut hi = 1u32;
393    while beat_at_bar(map, default_numerator, pickup, hi) < beat {
394        hi = hi.saturating_mul(2);
395        if hi == u32::MAX {
396            return hi;
397        }
398    }
399    while lo + 1 < hi {
400        let mid = lo + (hi - lo) / 2;
401        if beat_at_bar(map, default_numerator, pickup, mid) < beat {
402            lo = mid;
403        } else {
404            hi = mid;
405        }
406    }
407    hi
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn new_normalizes_by_gcd() {
416        assert_eq!(Beat::new(2, 4), Beat::new(1, 2));
417        assert_eq!(Beat::new(-3, 6), Beat { num: -1, den: 2 });
418        assert_eq!(Beat::new(7, 1), Beat { num: 7, den: 1 });
419        // Zero is canonical and a zero denominator can't divide by zero.
420        assert_eq!(Beat::new(0, 7), Beat::zero());
421        assert_eq!(Beat::new(5, 0), Beat::from_int(5));
422    }
423
424    #[test]
425    fn orders_across_denominators() {
426        assert!(Beat::new(1, 3) < Beat::new(1, 2));
427        assert!(Beat::new(2, 3) > Beat::new(1, 2));
428        assert_eq!(
429            Beat::new(2, 4).cmp(&Beat::new(1, 2)),
430            std::cmp::Ordering::Equal
431        );
432        // Sorting mixes denominators correctly.
433        let mut v = vec![Beat::new(3, 4), Beat::new(1, 3), Beat::new(1, 2)];
434        v.sort();
435        assert_eq!(v, vec![Beat::new(1, 3), Beat::new(1, 2), Beat::new(3, 4)]);
436    }
437
438    #[test]
439    fn comparison_never_overflows() {
440        // Naive i64 cross-multiplication would overflow here; i128 doesn't.
441        let a = Beat::new(i64::MAX, u32::MAX);
442        let b = Beat::new(i64::MAX, u32::MAX - 1);
443        assert!(a < b, "same numerator, smaller denominator is larger");
444        assert!(Beat::new(i64::MAX, 1) > Beat::new(1, u32::MAX));
445        assert!(Beat::new(i64::MIN, 1) < Beat::new(-1, u32::MAX));
446    }
447
448    #[test]
449    fn add_sub_stay_exact() {
450        assert_eq!(
451            Beat::new(1, 3).checked_add(Beat::new(1, 6)).unwrap(),
452            Beat::new(1, 2)
453        );
454        assert_eq!(
455            Beat::new(1, 2).checked_sub(Beat::new(1, 3)).unwrap(),
456            Beat::new(1, 6)
457        );
458        assert_eq!(
459            Beat::zero().checked_sub(Beat::new(1, 4)).unwrap(),
460            Beat::new(-1, 4)
461        );
462    }
463
464    #[test]
465    fn add_reports_overflow() {
466        assert_eq!(
467            Beat::new(i64::MAX, 1).checked_add(Beat::from_int(1)),
468            Err(BeatError::Overflow)
469        );
470        assert_eq!(
471            Beat::new(1, u32::MAX).checked_add(Beat::new(1, u32::MAX - 1)),
472            Err(BeatError::Overflow),
473            "the unreduced denominator u32::MAX * (u32::MAX - 1) doesn't fit"
474        );
475    }
476
477    #[test]
478    fn scales_rationally() {
479        assert_eq!(Beat::new(2, 3).mul_rational(3, 4).unwrap(), Beat::new(1, 2));
480        assert_eq!(Beat::new(1, 2).scale(3).unwrap(), Beat::new(3, 2));
481        assert_eq!(Beat::new(1, 3).mul_rational(0, 1).unwrap(), Beat::zero());
482    }
483
484    #[test]
485    fn triplet_math_is_exact_at_the_frame_boundary() {
486        // 1/3 beat at 120 BPM = 1/6 s; at 48 kHz that is EXACTLY 8000 frames.
487        assert_eq!(
488            beat_to_frames(Beat::new(1, 3), Tempo(120.0), SampleRate(48_000)),
489            Frames(8000)
490        );
491        // A whole beat at 120 BPM / 48 kHz = 0.5 s = 24000 frames.
492        assert_eq!(
493            beat_to_frames(Beat::from_int(1), Tempo(120.0), SampleRate(48_000)),
494            Frames(24_000)
495        );
496    }
497
498    #[test]
499    fn rounds_half_away_from_zero() {
500        // 1 beat at 40 BPM = 1.5 s; at 3 Hz that lands exactly on 4.5 frames,
501        // and the specified rule rounds UP (banker's rounding would give 4).
502        assert_eq!(
503            beat_to_frames(Beat::from_int(1), Tempo(40.0), SampleRate(3)),
504            Frames(5)
505        );
506    }
507
508    #[test]
509    fn clamps_degenerate_inputs() {
510        // Tempo below 1 BPM floors at 1: 1 beat = 60 s at 48 kHz.
511        assert_eq!(
512            beat_to_frames(Beat::from_int(1), Tempo(0.5), SampleRate(48_000)),
513            Frames(2_880_000)
514        );
515        // A position before the song's start has no frame: clamps to 0
516        // (even though -4.5 would round to -5 away from zero).
517        assert_eq!(
518            beat_to_frames(Beat::new(-1, 2), Tempo(120.0), SampleRate(48_000)),
519            Frames(0)
520        );
521    }
522
523    #[test]
524    fn integer_units_do_the_obvious_arithmetic() {
525        assert_eq!(Frames(10) + Frames(5), Frames(15));
526        assert_eq!(Frames(10) - Frames(5), Frames(5));
527        assert_eq!(Bars(1) + Bars(2), Bars(3));
528        assert_eq!(Frames::from(3u64), Frames(3));
529        assert!(SampleRate(96_000) > SampleRate(44_100));
530    }
531
532    #[test]
533    fn beat_displays_compactly() {
534        assert_eq!(Beat::new(3, 2).to_string(), "3/2");
535        assert_eq!(Beat::from_int(4).to_string(), "4");
536        assert_eq!(Beat::new(-1, 2).to_string(), "-1/2");
537    }
538
539    #[test]
540    fn beat_serde_is_a_flat_normalized_struct() {
541        assert_eq!(
542            serde_json::to_string(&Beat::new(1, 2)).unwrap(),
543            r#"{"num":1,"den":2}"#
544        );
545        // Deserializing normalizes: 2/4 comes back as 1/2.
546        let b: Beat = serde_json::from_str(r#"{"num":2,"den":4}"#).unwrap();
547        assert_eq!(b, Beat::new(1, 2));
548    }
549
550    #[test]
551    fn units_serialize_as_the_bare_inner_value() {
552        assert_eq!(serde_json::to_string(&Frames(8)).unwrap(), "8");
553        assert_eq!(serde_json::to_string(&Tempo(127.5)).unwrap(), "127.5");
554        let f: Frames = serde_json::from_str("8").unwrap();
555        assert_eq!(f, Frames(8));
556    }
557}