Skip to main content

oxideav_core/
time.rs

1//! Time base and timestamp types.
2
3use crate::rational::Rational;
4
5/// A time base expressed as a rational number of seconds per tick.
6///
7/// A `TimeBase` of 1/48000 means each timestamp unit is 1/48000 second.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub struct TimeBase(pub Rational);
10
11impl TimeBase {
12    /// Construct a time base of `num/den` seconds per tick.
13    pub const fn new(num: i64, den: i64) -> Self {
14        Self(Rational::new(num, den))
15    }
16
17    /// Construct a `TimeBase` representing `1/rate` seconds per tick —
18    /// the canonical "sample-rate-style" base used by audio codecs
19    /// (`1/48000` for 48 kHz PCM, `1/44100` for CD audio, `1/8000` for
20    /// G.711) and by the common video bases (`1/90000` for MPEG-TS,
21    /// `1/1000000` for microsecond PTS).
22    ///
23    /// Equivalent to `TimeBase::new(1, rate as i64)`, but reads more
24    /// clearly at call sites and documents the inverse-of-rate
25    /// convention so a reader doesn't have to mentally swap arguments.
26    pub const fn from_rate(rate: u32) -> Self {
27        Self(Rational::new(1, rate as i64))
28    }
29
30    /// `num` of the underlying [`Rational`]. Sugar over `tb.0.num` for
31    /// callers that don't want to reach through the tuple-struct field.
32    pub const fn num(&self) -> i64 {
33        self.0.num
34    }
35
36    /// `den` of the underlying [`Rational`]. Sugar over `tb.0.den`.
37    pub const fn den(&self) -> i64 {
38        self.0.den
39    }
40
41    /// The underlying seconds-per-tick fraction.
42    pub fn as_rational(&self) -> Rational {
43        self.0
44    }
45
46    /// `true` when this time base is usable for rescaling — both terms
47    /// non-zero. A zero denominator denotes "no defined time base" (the
48    /// `1/0` placeholder some demuxers stamp on data-only streams);
49    /// callers that want to skip rescaling on those streams can branch
50    /// on `is_valid()` instead of re-doing the same `den != 0 && num != 0`
51    /// check at every call site.
52    pub const fn is_valid(&self) -> bool {
53        self.0.num != 0 && self.0.den != 0
54    }
55
56    /// Convert a tick count in this time base to seconds.
57    pub fn seconds_of(&self, ticks: i64) -> f64 {
58        ticks as f64 * self.0.as_f64()
59    }
60
61    /// Convert a fractional-seconds count to the nearest tick count in
62    /// this time base. The inverse of [`seconds_of`](Self::seconds_of):
63    /// `seconds_of` goes
64    /// `ticks → seconds`; `ticks_of` goes `seconds → ticks`. Useful
65    /// for muxers and encoders that have a target wall-clock duration
66    /// and need to land it on the stream's time base without hand-rolling
67    /// the divide-and-round at every call site.
68    ///
69    /// Rounds half-away-from-zero (matches [`rescale`]). On an invalid
70    /// time base (`is_valid() == false`) or when the result would exceed
71    /// `i64` range, returns `0` — pick a defaulted timestamp rather than
72    /// panicking, since callers are typically muxing best-effort output.
73    pub fn ticks_of(&self, seconds: f64) -> i64 {
74        // ticks = seconds / (num/den) = seconds * den / num
75        if !self.is_valid() || !seconds.is_finite() {
76            return 0;
77        }
78        let scaled = seconds * (self.0.den as f64) / (self.0.num as f64);
79        if !scaled.is_finite() {
80            return 0;
81        }
82        // Half-away-from-zero rounding, matching `rescale`.
83        let rounded = if scaled >= 0.0 {
84            (scaled + 0.5).floor()
85        } else {
86            (scaled - 0.5).ceil()
87        };
88        // Clamp to i64 range.
89        if rounded >= i64::MAX as f64 {
90            i64::MAX
91        } else if rounded <= i64::MIN as f64 {
92            i64::MIN
93        } else {
94            rounded as i64
95        }
96    }
97
98    /// Rescale a timestamp from this time base to another.
99    ///
100    /// Saturates at the `i64` range boundaries and returns `0` on an
101    /// undefined conversion (zero term in the factor) — see [`rescale`].
102    pub fn rescale(&self, ts: i64, target: TimeBase) -> i64 {
103        rescale(ts, self.0, target.0)
104    }
105
106    /// Rescale a timestamp from this time base to another with an
107    /// explicit [`Rounding`] mode. See [`rescale_rnd`].
108    pub fn rescale_rnd(&self, ts: i64, target: TimeBase, rounding: Rounding) -> i64 {
109        rescale_rnd(ts, self.0, target.0, rounding)
110    }
111
112    /// Rescale a timestamp from this time base to another, reporting
113    /// `None` instead of saturating or defaulting — see
114    /// [`rescale_checked`].
115    pub fn rescale_checked(&self, ts: i64, target: TimeBase) -> Option<i64> {
116        rescale_checked(ts, self.0, target.0)
117    }
118}
119
120/// Common time-base constants.
121///
122/// These are the rates that show up over and over across the workspace:
123/// MPEG-TS / RTP video at 90 kHz, microsecond PTS (most demuxers'
124/// "expose-everything" base), MKV at 1 ms, and the audio sample rates
125/// the codec crates spend most of their lives at. Naming them once
126/// removes the magic-numbers-at-call-sites that grep-fishing has to
127/// distinguish from random integer literals.
128impl TimeBase {
129    /// 1/1 — one tick per second. The "no rescaling" identity base,
130    /// useful for placeholders on streams without a defined cadence
131    /// (e.g. one-shot SVG / image frames).
132    pub const SECONDS: TimeBase = TimeBase::new(1, 1);
133
134    /// 1/1000 — millisecond ticks (Matroska / WebM `Timecode` default).
135    pub const MILLIS: TimeBase = TimeBase::new(1, 1_000);
136
137    /// 1/1_000_000 — microsecond ticks (the base most demuxers expose
138    /// to consumers when they want the finest sane resolution without
139    /// going to nanoseconds).
140    pub const MICROS: TimeBase = TimeBase::new(1, 1_000_000);
141
142    /// 1/1_000_000_000 — nanosecond ticks.
143    pub const NANOS: TimeBase = TimeBase::new(1, 1_000_000_000);
144
145    /// 1/90000 — 90 kHz, the MPEG-TS / RTP video PTS clock.
146    pub const MPEG_TS: TimeBase = TimeBase::new(1, 90_000);
147
148    /// 1/48000 — 48 kHz audio sample-clock (Opus, AC-3, most modern
149    /// AAC, DTS).
150    pub const AUDIO_48K: TimeBase = TimeBase::new(1, 48_000);
151
152    /// 1/44100 — 44.1 kHz audio sample-clock (CD audio, MP3 at 44.1,
153    /// many FLAC streams).
154    pub const AUDIO_44K1: TimeBase = TimeBase::new(1, 44_100);
155
156    /// 1/8000 — 8 kHz audio sample-clock (G.711, G.722, G.729, AMR-NB).
157    pub const AUDIO_8K: TimeBase = TimeBase::new(1, 8_000);
158}
159
160/// A timestamp in a particular time base.
161#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
162pub struct Timestamp {
163    /// Tick count in `base` units.
164    pub value: i64,
165    /// The time base the tick count is expressed in.
166    pub base: TimeBase,
167}
168
169impl Timestamp {
170    /// Construct a timestamp of `value` ticks in `base`.
171    pub const fn new(value: i64, base: TimeBase) -> Self {
172        Self { value, base }
173    }
174
175    /// Construct a timestamp at `seconds` in the given `base`, rounded
176    /// to the nearest tick. Sugar over `Timestamp::new(base.ticks_of(s), base)`.
177    pub fn from_seconds(seconds: f64, base: TimeBase) -> Self {
178        Self::new(base.ticks_of(seconds), base)
179    }
180
181    /// The timestamp as fractional seconds (`value × base`).
182    pub fn seconds(&self) -> f64 {
183        self.base.seconds_of(self.value)
184    }
185
186    /// Rescale onto `target` with half-away-from-zero rounding; the
187    /// saturating/defaulting semantics of [`rescale`].
188    pub fn rescale(&self, target: TimeBase) -> Self {
189        Self {
190            value: self.base.rescale(self.value, target),
191            base: target,
192        }
193    }
194
195    /// Rescale onto `target` with an explicit [`Rounding`] mode. Muxers
196    /// that must never stamp a DTS later than the true instant use
197    /// [`Rounding::Floor`]; [`Rounding::NearestAway`] reproduces
198    /// [`rescale`](Self::rescale).
199    pub fn rescale_rnd(&self, target: TimeBase, rounding: Rounding) -> Self {
200        Self {
201            value: self.base.rescale_rnd(self.value, target, rounding),
202            base: target,
203        }
204    }
205
206    /// Rescale onto `target`, returning `None` when the conversion is
207    /// undefined (zero term in the factor) or the result doesn't fit
208    /// `i64` — instead of the defaulting/saturating [`rescale`](Self::rescale).
209    pub fn checked_rescale(&self, target: TimeBase) -> Option<Self> {
210        self.base
211            .rescale_checked(self.value, target)
212            .map(|value| Self {
213                value,
214                base: target,
215            })
216    }
217
218    /// Advance the timestamp by `ticks` units in its own base. Returns
219    /// `None` on `i64` overflow rather than wrapping silently — muxers
220    /// that compute a packet-end timestamp at the edge of the
221    /// representable range get a clean signal instead of a wrap.
222    pub fn checked_add_ticks(&self, ticks: i64) -> Option<Self> {
223        self.value.checked_add(ticks).map(|v| Self {
224            value: v,
225            base: self.base,
226        })
227    }
228
229    /// Move the timestamp backwards by `ticks` units in its own base.
230    /// Returns `None` on `i64` overflow.
231    pub fn checked_sub_ticks(&self, ticks: i64) -> Option<Self> {
232        self.value.checked_sub(ticks).map(|v| Self {
233            value: v,
234            base: self.base,
235        })
236    }
237
238    /// Tick-difference `self - other` after rescaling `other` onto
239    /// `self`'s base. Returns `None` when the subtraction would overflow
240    /// `i64` (rare in practice but easy to surface cleanly).
241    ///
242    /// Use this to compute the duration between two `Timestamp`s that
243    /// may have been produced by different sources (e.g. a packet from a
244    /// container demuxer minus a packet from a different demuxer in a
245    /// remux pipeline).
246    pub fn checked_diff(&self, other: Timestamp) -> Option<i64> {
247        let other_in_self_base = other.rescale(self.base).value;
248        self.value.checked_sub(other_in_self_base)
249    }
250}
251
252/// How a rescale operation rounds a result that falls between two
253/// integer ticks of the target base.
254#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
255#[non_exhaustive]
256pub enum Rounding {
257    /// Round to the nearest tick; a tie (exactly halfway) rounds away
258    /// from zero (`+1.5 → +2`, `-1.5 → -2`). The default, and the mode
259    /// the plain [`rescale`] uses.
260    #[default]
261    NearestAway,
262    /// Round toward negative infinity. The right choice for DTS-like
263    /// stamps that must never land *after* the true instant.
264    Floor,
265    /// Round toward positive infinity. The mirror of [`Rounding::Floor`]
266    /// for end-of-range stamps that must never land *before* the true
267    /// instant.
268    Ceil,
269    /// Round toward zero (truncate the fractional part).
270    TowardZero,
271}
272
273/// Divide `|prod|` by `den` with the given rounding mode, in unsigned
274/// magnitude space so no intermediate can overflow (`p ≤ 2^127`,
275/// `d ≤ 2^126`, and every adjustment stays below `2^128`). `neg` is the
276/// sign of the true quotient; sign-dependent modes (floor / ceil) use
277/// it to pick the right direction.
278fn div_round_abs(p: u128, d: u128, neg: bool, rounding: Rounding) -> u128 {
279    let q = p / d;
280    let r = p % d;
281    // Whether the magnitude rounds up to q+1. Remainder-based so no
282    // intermediate exceeds u128 (`r < d ≤ 2^126`, so `r * 2 < 2^127`).
283    let bump = match rounding {
284        // Ties (r*2 == d) round the magnitude up = away from zero.
285        Rounding::NearestAway => r * 2 >= d,
286        // Floor rounds a negative quotient's magnitude up, a positive
287        // one down; Ceil is the mirror.
288        Rounding::Floor => neg && r != 0,
289        Rounding::Ceil => !neg && r != 0,
290        Rounding::TowardZero => false,
291    };
292    if bump {
293        // q + 1 can only wrap when q == u128::MAX (p = 2^128-1, d = 1);
294        // the saturated value narrows to the same saturated i64 anyway.
295        q.saturating_add(1)
296    } else {
297        q
298    }
299}
300
301/// Narrow a sign+magnitude quotient to `i64`, saturating out-of-range
302/// magnitudes.
303fn sat_narrow(neg: bool, q_abs: u128) -> i64 {
304    if neg {
305        if q_abs >= 1u128 << 63 {
306            i64::MIN
307        } else {
308            -(q_abs as i64)
309        }
310    } else if q_abs > i64::MAX as u128 {
311        i64::MAX
312    } else {
313        q_abs as i64
314    }
315}
316
317/// Narrow a sign+magnitude quotient to `i64`, reporting `None` for
318/// out-of-range magnitudes.
319fn checked_narrow(neg: bool, q_abs: u128) -> Option<i64> {
320    if neg {
321        if q_abs > 1u128 << 63 {
322            None
323        } else {
324            Some((q_abs as i128).wrapping_neg() as i64)
325        }
326    } else if q_abs > i64::MAX as u128 {
327        None
328    } else {
329        Some(q_abs as i64)
330    }
331}
332
333/// Split the rescale factor into `(numerator, positive denominator)`,
334/// folding the denominator's sign onto the numerator. `None` when the
335/// denominator is zero (undefined conversion).
336fn rescale_factor(from: Rational, to: Rational) -> Option<(i128, u128)> {
337    // value * (from.num/from.den) / (to.num/to.den)
338    //   = value * from.num * to.den / (from.den * to.num)
339    let mut num = from.num as i128 * to.den as i128;
340    let den = from.den as i128 * to.num as i128;
341    if den == 0 {
342        return None;
343    }
344    if den < 0 {
345        // |num| ≤ 2^126, so the negation cannot overflow.
346        num = -num;
347    }
348    Some((num, den.unsigned_abs()))
349}
350
351/// Rescale a value from one rational time base to another using 128-bit
352/// intermediate arithmetic. Rounding is half-away-from-zero: a tie
353/// rounds toward the larger magnitude (e.g. `+1.5 → +2`, `-1.5 → -2`).
354///
355/// Total — never panics or wraps: an undefined conversion factor
356/// (`from.den * to.num == 0`) returns `0`, and a result outside `i64`
357/// range **saturates** to `i64::MAX` / `i64::MIN`. Use
358/// [`rescale_checked`] to detect those cases instead, or
359/// [`rescale_rnd`] for a different rounding mode.
360pub fn rescale(value: i64, from: Rational, to: Rational) -> i64 {
361    rescale_rnd(value, from, to, Rounding::NearestAway)
362}
363
364/// [`rescale`] with an explicit [`Rounding`] mode. Same totality
365/// guarantees: `0` on an undefined factor, saturation at the `i64`
366/// boundaries.
367pub fn rescale_rnd(value: i64, from: Rational, to: Rational, rounding: Rounding) -> i64 {
368    let Some((num, den)) = rescale_factor(from, to) else {
369        return 0;
370    };
371    let neg = (value < 0) != (num < 0);
372    // |value| ≤ 2^63 and |num| ≤ 2^126, so the magnitude product can
373    // reach ~2^189 with pathological bases; the true result is then far
374    // outside i64 either way, so saturate by sign.
375    let Some(p) = (value.unsigned_abs() as u128).checked_mul(num.unsigned_abs()) else {
376        return if neg { i64::MIN } else { i64::MAX };
377    };
378    sat_narrow(neg && p != 0, div_round_abs(p, den, neg, rounding))
379}
380
381/// [`rescale`] that reports failure instead of papering over it:
382/// returns `None` when the conversion factor is undefined
383/// (`from.den * to.num == 0`) or the rounded result doesn't fit `i64`.
384/// Rounding is half-away-from-zero, matching [`rescale`].
385pub fn rescale_checked(value: i64, from: Rational, to: Rational) -> Option<i64> {
386    let (num, den) = rescale_factor(from, to)?;
387    let neg = (value < 0) != (num < 0);
388    let p = (value.unsigned_abs() as u128).checked_mul(num.unsigned_abs())?;
389    checked_narrow(
390        neg && p != 0,
391        div_round_abs(p, den, neg, Rounding::NearestAway),
392    )
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn rescale_samples_to_pts() {
401        // 48000 samples at 1/48000 base → 1 second at 1/1000 base = 1000 ticks
402        assert_eq!(
403            rescale(48000, Rational::new(1, 48000), Rational::new(1, 1000)),
404            1000
405        );
406    }
407
408    #[test]
409    fn timestamp_seconds() {
410        let ts = Timestamp::new(48000, TimeBase::new(1, 48000));
411        assert!((ts.seconds() - 1.0).abs() < 1e-9);
412    }
413
414    #[test]
415    fn rescale_rounds_half_away_from_zero() {
416        // 1 tick at 1/2 s/tick → 1/1 base = 0.5 → ties up to 1.
417        assert_eq!(rescale(1, Rational::new(1, 2), Rational::new(1, 1)), 1);
418        // -1 tick at 1/2 s/tick → -0.5 → ties to -1 (away from zero).
419        assert_eq!(rescale(-1, Rational::new(1, 2), Rational::new(1, 1)), -1);
420        // 3 ticks at 1/2 → 1.5 → 2.
421        assert_eq!(rescale(3, Rational::new(1, 2), Rational::new(1, 1)), 2);
422        assert_eq!(rescale(-3, Rational::new(1, 2), Rational::new(1, 1)), -2);
423    }
424
425    #[test]
426    fn rescale_saturates_instead_of_wrapping() {
427        // i64::MAX seconds → milliseconds overflows ×1000; the result
428        // used to wrap through `as i64`, now it saturates.
429        assert_eq!(
430            rescale(i64::MAX, Rational::new(1, 1), Rational::new(1, 1000)),
431            i64::MAX
432        );
433        assert_eq!(
434            rescale(i64::MIN, Rational::new(1, 1), Rational::new(1, 1000)),
435            i64::MIN
436        );
437        // Sign flip through a negative factor saturates the other way.
438        assert_eq!(
439            rescale(i64::MAX, Rational::new(-1, 1), Rational::new(1, 1000)),
440            i64::MIN
441        );
442        // Pathological factor whose 128-bit product overflows: still
443        // saturates by sign instead of panicking.
444        assert_eq!(
445            rescale(
446                i64::MAX,
447                Rational::new(i64::MAX, 1),
448                Rational::new(1, i64::MAX)
449            ),
450            i64::MAX
451        );
452        // Undefined factor (zero denominator term) stays 0.
453        assert_eq!(rescale(5, Rational::new(1, 0), Rational::new(1, 1)), 0);
454        assert_eq!(rescale(5, Rational::new(1, 1), Rational::new(0, 1)), 0);
455    }
456
457    #[test]
458    fn rescale_negative_denominator_ties_away_from_zero() {
459        // 3 ticks × (1/1) ÷ (-2/1) = -1.5 → -2 (half away from zero).
460        // The old sign handling rounded these ties toward zero.
461        assert_eq!(rescale(3, Rational::new(1, 1), Rational::new(-2, 1)), -2);
462        assert_eq!(rescale(-3, Rational::new(1, 1), Rational::new(-2, 1)), 2);
463        // Non-tie sanity through a negative source den.
464        assert_eq!(rescale(4, Rational::new(1, -2), Rational::new(1, 1)), -2);
465    }
466
467    #[test]
468    fn rescale_checked_reports_failure() {
469        // In-range conversions match the plain rescale.
470        assert_eq!(
471            rescale_checked(48000, Rational::new(1, 48000), Rational::new(1, 1000)),
472            Some(1000)
473        );
474        // Overflow → None (plain rescale saturates).
475        assert_eq!(
476            rescale_checked(i64::MAX, Rational::new(1, 1), Rational::new(1, 1000)),
477            None
478        );
479        // Undefined factor → None (plain rescale returns 0).
480        assert_eq!(
481            rescale_checked(5, Rational::new(1, 0), Rational::new(1, 1)),
482            None
483        );
484        assert_eq!(
485            rescale_checked(5, Rational::new(1, 1), Rational::new(0, 1)),
486            None
487        );
488        // Exactly i64::MIN is representable, one tick below is not.
489        assert_eq!(
490            rescale_checked(i64::MIN, Rational::new(1, 1), Rational::new(1, 1)),
491            Some(i64::MIN)
492        );
493        assert_eq!(
494            rescale_checked(i64::MIN, Rational::new(2, 1), Rational::new(1, 1)),
495            None
496        );
497    }
498
499    #[test]
500    fn rescale_rnd_modes() {
501        let from = Rational::new(1, 2);
502        let to = Rational::new(1, 1);
503        // 5 × (1/2) = 2.5
504        assert_eq!(rescale_rnd(5, from, to, Rounding::NearestAway), 3);
505        assert_eq!(rescale_rnd(5, from, to, Rounding::Floor), 2);
506        assert_eq!(rescale_rnd(5, from, to, Rounding::Ceil), 3);
507        assert_eq!(rescale_rnd(5, from, to, Rounding::TowardZero), 2);
508        // -5 × (1/2) = -2.5
509        assert_eq!(rescale_rnd(-5, from, to, Rounding::NearestAway), -3);
510        assert_eq!(rescale_rnd(-5, from, to, Rounding::Floor), -3);
511        assert_eq!(rescale_rnd(-5, from, to, Rounding::Ceil), -2);
512        assert_eq!(rescale_rnd(-5, from, to, Rounding::TowardZero), -2);
513        // Exact results are mode-independent.
514        for mode in [
515            Rounding::NearestAway,
516            Rounding::Floor,
517            Rounding::Ceil,
518            Rounding::TowardZero,
519        ] {
520            assert_eq!(rescale_rnd(4, from, to, mode), 2);
521        }
522        // Default mode is NearestAway (matches plain rescale).
523        assert_eq!(
524            rescale_rnd(5, from, to, Rounding::default()),
525            rescale(5, from, to)
526        );
527    }
528
529    #[test]
530    fn timestamp_rescale_rnd_and_checked() {
531        // 1 tick at 1/3 s → milliseconds = 333.33…
532        let ts = Timestamp::new(1, TimeBase::new(1, 3));
533        assert_eq!(ts.rescale_rnd(TimeBase::MILLIS, Rounding::Floor).value, 333);
534        assert_eq!(ts.rescale_rnd(TimeBase::MILLIS, Rounding::Ceil).value, 334);
535        assert_eq!(
536            ts.rescale_rnd(TimeBase::MILLIS, Rounding::Ceil).base,
537            TimeBase::MILLIS
538        );
539        // checked_rescale mirrors rescale in range…
540        let ok = Timestamp::new(48_000, TimeBase::AUDIO_48K)
541            .checked_rescale(TimeBase::MILLIS)
542            .unwrap();
543        assert_eq!(ok.value, 1000);
544        assert_eq!(ok.base, TimeBase::MILLIS);
545        // …and reports None past it.
546        let edge = Timestamp::new(i64::MAX, TimeBase::SECONDS);
547        assert!(edge.checked_rescale(TimeBase::MILLIS).is_none());
548        assert_eq!(edge.rescale(TimeBase::MILLIS).value, i64::MAX);
549    }
550
551    #[test]
552    fn from_rate_matches_long_form() {
553        assert_eq!(TimeBase::from_rate(48_000), TimeBase::new(1, 48_000));
554        assert_eq!(TimeBase::from_rate(90_000), TimeBase::new(1, 90_000));
555        assert_eq!(TimeBase::from_rate(1), TimeBase::new(1, 1));
556    }
557
558    #[test]
559    fn num_den_accessors() {
560        let tb = TimeBase::new(1, 90_000);
561        assert_eq!(tb.num(), 1);
562        assert_eq!(tb.den(), 90_000);
563        // Const-context callable.
564        const NUM: i64 = TimeBase::AUDIO_48K.num();
565        const DEN: i64 = TimeBase::AUDIO_48K.den();
566        assert_eq!(NUM, 1);
567        assert_eq!(DEN, 48_000);
568    }
569
570    #[test]
571    fn is_valid_rejects_zero_terms() {
572        assert!(TimeBase::new(1, 1000).is_valid());
573        // Den == 0: undefined rate.
574        assert!(!TimeBase::new(1, 0).is_valid());
575        // Num == 0: degenerate ratio (everything is zero seconds).
576        assert!(!TimeBase::new(0, 1).is_valid());
577    }
578
579    #[test]
580    fn ticks_of_is_inverse_of_seconds_of() {
581        // 1 second on a 1/48000 base = 48000 ticks.
582        assert_eq!(TimeBase::AUDIO_48K.ticks_of(1.0), 48_000);
583        // 1 second on a 1/90000 base = 90000 ticks.
584        assert_eq!(TimeBase::MPEG_TS.ticks_of(1.0), 90_000);
585        // 0.5 second on 1/1000 base = 500 ticks.
586        assert_eq!(TimeBase::MILLIS.ticks_of(0.5), 500);
587        // Round-trip on integer multiples.
588        let tb = TimeBase::AUDIO_44K1;
589        assert_eq!(tb.ticks_of(tb.seconds_of(44_100)), 44_100);
590    }
591
592    #[test]
593    fn ticks_of_rounds_half_away_from_zero() {
594        // 0.5 tick on 1/1 base → 1 (positive ties up).
595        assert_eq!(TimeBase::SECONDS.ticks_of(0.5), 1);
596        // -0.5 tick on 1/1 base → -1 (negative ties down).
597        assert_eq!(TimeBase::SECONDS.ticks_of(-0.5), -1);
598        // 1.5 ticks → 2.
599        assert_eq!(TimeBase::SECONDS.ticks_of(1.5), 2);
600        // -1.5 ticks → -2.
601        assert_eq!(TimeBase::SECONDS.ticks_of(-1.5), -2);
602    }
603
604    #[test]
605    fn ticks_of_invalid_inputs() {
606        // Invalid time base → 0.
607        assert_eq!(TimeBase::new(1, 0).ticks_of(1.0), 0);
608        assert_eq!(TimeBase::new(0, 1).ticks_of(1.0), 0);
609        // Non-finite seconds → 0.
610        assert_eq!(TimeBase::MILLIS.ticks_of(f64::NAN), 0);
611        assert_eq!(TimeBase::MILLIS.ticks_of(f64::INFINITY), 0);
612        assert_eq!(TimeBase::MILLIS.ticks_of(f64::NEG_INFINITY), 0);
613    }
614
615    #[test]
616    fn common_constants_match_long_form() {
617        assert_eq!(TimeBase::SECONDS, TimeBase::new(1, 1));
618        assert_eq!(TimeBase::MILLIS, TimeBase::new(1, 1_000));
619        assert_eq!(TimeBase::MICROS, TimeBase::new(1, 1_000_000));
620        assert_eq!(TimeBase::NANOS, TimeBase::new(1, 1_000_000_000));
621        assert_eq!(TimeBase::MPEG_TS, TimeBase::new(1, 90_000));
622        assert_eq!(TimeBase::AUDIO_48K, TimeBase::new(1, 48_000));
623        assert_eq!(TimeBase::AUDIO_44K1, TimeBase::new(1, 44_100));
624        assert_eq!(TimeBase::AUDIO_8K, TimeBase::new(1, 8_000));
625    }
626
627    #[test]
628    fn timestamp_from_seconds() {
629        let ts = Timestamp::from_seconds(1.0, TimeBase::AUDIO_48K);
630        assert_eq!(ts.value, 48_000);
631        assert_eq!(ts.base, TimeBase::AUDIO_48K);
632        // Round-trip.
633        assert!((ts.seconds() - 1.0).abs() < 1e-9);
634    }
635
636    #[test]
637    fn checked_add_sub_ticks_round_trip() {
638        let ts = Timestamp::new(100, TimeBase::MILLIS);
639        assert_eq!(ts.checked_add_ticks(50).unwrap().value, 150);
640        assert_eq!(ts.checked_sub_ticks(50).unwrap().value, 50);
641        // Base unchanged through the arithmetic.
642        assert_eq!(ts.checked_add_ticks(50).unwrap().base, TimeBase::MILLIS);
643    }
644
645    #[test]
646    fn checked_add_ticks_detects_overflow() {
647        let ts = Timestamp::new(i64::MAX - 5, TimeBase::SECONDS);
648        assert!(ts.checked_add_ticks(10).is_none());
649        // Boundary case: i64::MAX exactly is fine.
650        let near_max = Timestamp::new(i64::MAX - 1, TimeBase::SECONDS);
651        assert_eq!(near_max.checked_add_ticks(1).unwrap().value, i64::MAX);
652    }
653
654    #[test]
655    fn checked_sub_ticks_detects_overflow() {
656        let ts = Timestamp::new(i64::MIN + 5, TimeBase::SECONDS);
657        assert!(ts.checked_sub_ticks(10).is_none());
658    }
659
660    #[test]
661    fn checked_diff_rescales_other_onto_self_base() {
662        // 1 second at 1/48000 minus 500ms at 1/1000 = 500ms = 24000 ticks at 48k.
663        let a = Timestamp::new(48_000, TimeBase::AUDIO_48K); // 1.0s
664        let b = Timestamp::new(500, TimeBase::MILLIS); // 0.5s
665        assert_eq!(a.checked_diff(b), Some(24_000));
666    }
667
668    #[test]
669    fn checked_diff_same_base() {
670        let a = Timestamp::new(1000, TimeBase::MILLIS);
671        let b = Timestamp::new(250, TimeBase::MILLIS);
672        assert_eq!(a.checked_diff(b), Some(750));
673        assert_eq!(b.checked_diff(a), Some(-750));
674    }
675}