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    pub const fn new(num: i64, den: i64) -> Self {
13        Self(Rational::new(num, den))
14    }
15
16    /// Construct a `TimeBase` representing `1/rate` seconds per tick —
17    /// the canonical "sample-rate-style" base used by audio codecs
18    /// (`1/48000` for 48 kHz PCM, `1/44100` for CD audio, `1/8000` for
19    /// G.711) and by the common video bases (`1/90000` for MPEG-TS,
20    /// `1/1000000` for microsecond PTS).
21    ///
22    /// Equivalent to `TimeBase::new(1, rate as i64)`, but reads more
23    /// clearly at call sites and documents the inverse-of-rate
24    /// convention so a reader doesn't have to mentally swap arguments.
25    pub const fn from_rate(rate: u32) -> Self {
26        Self(Rational::new(1, rate as i64))
27    }
28
29    /// `num` of the underlying [`Rational`]. Sugar over `tb.0.num` for
30    /// callers that don't want to reach through the tuple-struct field.
31    pub const fn num(&self) -> i64 {
32        self.0.num
33    }
34
35    /// `den` of the underlying [`Rational`]. Sugar over `tb.0.den`.
36    pub const fn den(&self) -> i64 {
37        self.0.den
38    }
39
40    pub fn as_rational(&self) -> Rational {
41        self.0
42    }
43
44    /// `true` when this time base is usable for rescaling — both terms
45    /// non-zero. A zero denominator denotes "no defined time base" (the
46    /// `1/0` placeholder some demuxers stamp on data-only streams);
47    /// callers that want to skip rescaling on those streams can branch
48    /// on `is_valid()` instead of re-doing the same `den != 0 && num != 0`
49    /// check at every call site.
50    pub const fn is_valid(&self) -> bool {
51        self.0.num != 0 && self.0.den != 0
52    }
53
54    /// Convert a tick count in this time base to seconds.
55    pub fn seconds_of(&self, ticks: i64) -> f64 {
56        ticks as f64 * self.0.as_f64()
57    }
58
59    /// Convert a fractional-seconds count to the nearest tick count in
60    /// this time base. The inverse of [`seconds_of`]: `seconds_of` goes
61    /// `ticks → seconds`; `ticks_of` goes `seconds → ticks`. Useful
62    /// for muxers and encoders that have a target wall-clock duration
63    /// and need to land it on the stream's time base without hand-rolling
64    /// the divide-and-round at every call site.
65    ///
66    /// Rounds half-away-from-zero (matches [`rescale`]). On an invalid
67    /// time base (`is_valid() == false`) or when the result would exceed
68    /// `i64` range, returns `0` — pick a defaulted timestamp rather than
69    /// panicking, since callers are typically muxing best-effort output.
70    pub fn ticks_of(&self, seconds: f64) -> i64 {
71        // ticks = seconds / (num/den) = seconds * den / num
72        if !self.is_valid() || !seconds.is_finite() {
73            return 0;
74        }
75        let scaled = seconds * (self.0.den as f64) / (self.0.num as f64);
76        if !scaled.is_finite() {
77            return 0;
78        }
79        // Half-away-from-zero rounding, matching `rescale`.
80        let rounded = if scaled >= 0.0 {
81            (scaled + 0.5).floor()
82        } else {
83            (scaled - 0.5).ceil()
84        };
85        // Clamp to i64 range.
86        if rounded >= i64::MAX as f64 {
87            i64::MAX
88        } else if rounded <= i64::MIN as f64 {
89            i64::MIN
90        } else {
91            rounded as i64
92        }
93    }
94
95    /// Rescale a timestamp from this time base to another.
96    pub fn rescale(&self, ts: i64, target: TimeBase) -> i64 {
97        rescale(ts, self.0, target.0)
98    }
99}
100
101/// Common time-base constants.
102///
103/// These are the rates that show up over and over across the workspace:
104/// MPEG-TS / RTP video at 90 kHz, microsecond PTS (most demuxers'
105/// "expose-everything" base), MKV at 1 ms, and the audio sample rates
106/// the codec crates spend most of their lives at. Naming them once
107/// removes the magic-numbers-at-call-sites that grep-fishing has to
108/// distinguish from random integer literals.
109impl TimeBase {
110    /// 1/1 — one tick per second. The "no rescaling" identity base,
111    /// useful for placeholders on streams without a defined cadence
112    /// (e.g. one-shot SVG / image frames).
113    pub const SECONDS: TimeBase = TimeBase::new(1, 1);
114
115    /// 1/1000 — millisecond ticks (Matroska / WebM `Timecode` default).
116    pub const MILLIS: TimeBase = TimeBase::new(1, 1_000);
117
118    /// 1/1_000_000 — microsecond ticks (the base most demuxers expose
119    /// to consumers when they want the finest sane resolution without
120    /// going to nanoseconds).
121    pub const MICROS: TimeBase = TimeBase::new(1, 1_000_000);
122
123    /// 1/1_000_000_000 — nanosecond ticks.
124    pub const NANOS: TimeBase = TimeBase::new(1, 1_000_000_000);
125
126    /// 1/90000 — 90 kHz, the MPEG-TS / RTP video PTS clock.
127    pub const MPEG_TS: TimeBase = TimeBase::new(1, 90_000);
128
129    /// 1/48000 — 48 kHz audio sample-clock (Opus, AC-3, most modern
130    /// AAC, DTS).
131    pub const AUDIO_48K: TimeBase = TimeBase::new(1, 48_000);
132
133    /// 1/44100 — 44.1 kHz audio sample-clock (CD audio, MP3 at 44.1,
134    /// many FLAC streams).
135    pub const AUDIO_44K1: TimeBase = TimeBase::new(1, 44_100);
136
137    /// 1/8000 — 8 kHz audio sample-clock (G.711, G.722, G.729, AMR-NB).
138    pub const AUDIO_8K: TimeBase = TimeBase::new(1, 8_000);
139}
140
141/// A timestamp in a particular time base.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
143pub struct Timestamp {
144    pub value: i64,
145    pub base: TimeBase,
146}
147
148impl Timestamp {
149    pub const fn new(value: i64, base: TimeBase) -> Self {
150        Self { value, base }
151    }
152
153    /// Construct a timestamp at `seconds` in the given `base`, rounded
154    /// to the nearest tick. Sugar over `Timestamp::new(base.ticks_of(s), base)`.
155    pub fn from_seconds(seconds: f64, base: TimeBase) -> Self {
156        Self::new(base.ticks_of(seconds), base)
157    }
158
159    pub fn seconds(&self) -> f64 {
160        self.base.seconds_of(self.value)
161    }
162
163    pub fn rescale(&self, target: TimeBase) -> Self {
164        Self {
165            value: self.base.rescale(self.value, target),
166            base: target,
167        }
168    }
169
170    /// Advance the timestamp by `ticks` units in its own base. Returns
171    /// `None` on `i64` overflow rather than wrapping silently — muxers
172    /// that compute a packet-end timestamp at the edge of the
173    /// representable range get a clean signal instead of a wrap.
174    pub fn checked_add_ticks(&self, ticks: i64) -> Option<Self> {
175        self.value.checked_add(ticks).map(|v| Self {
176            value: v,
177            base: self.base,
178        })
179    }
180
181    /// Move the timestamp backwards by `ticks` units in its own base.
182    /// Returns `None` on `i64` overflow.
183    pub fn checked_sub_ticks(&self, ticks: i64) -> Option<Self> {
184        self.value.checked_sub(ticks).map(|v| Self {
185            value: v,
186            base: self.base,
187        })
188    }
189
190    /// Tick-difference `self - other` after rescaling `other` onto
191    /// `self`'s base. Returns `None` when the subtraction would overflow
192    /// `i64` (rare in practice but easy to surface cleanly).
193    ///
194    /// Use this to compute the duration between two `Timestamp`s that
195    /// may have been produced by different sources (e.g. a packet from a
196    /// container demuxer minus a packet from a different demuxer in a
197    /// remux pipeline).
198    pub fn checked_diff(&self, other: Timestamp) -> Option<i64> {
199        let other_in_self_base = other.rescale(self.base).value;
200        self.value.checked_sub(other_in_self_base)
201    }
202}
203
204/// Rescale a value from one rational time base to another using 128-bit
205/// intermediate arithmetic to avoid overflow. Rounding is half-away-from-zero:
206/// a tie rounds toward the larger magnitude (e.g. `+1.5 → +2`, `-1.5 → -2`),
207/// which the sign-aware `± half` adjustment below implements.
208pub fn rescale(value: i64, from: Rational, to: Rational) -> i64 {
209    // value * (from.num/from.den) / (to.num/to.den)
210    //   = value * from.num * to.den / (from.den * to.num)
211    let num = from.num as i128 * to.den as i128;
212    let den = from.den as i128 * to.num as i128;
213    if den == 0 {
214        return 0;
215    }
216    let prod = value as i128 * num;
217    let half = den.abs() / 2;
218    let rounded = if (prod >= 0) == (den > 0) {
219        (prod + half) / den
220    } else {
221        (prod - half) / den
222    };
223    rounded as i64
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn rescale_samples_to_pts() {
232        // 48000 samples at 1/48000 base → 1 second at 1/1000 base = 1000 ticks
233        assert_eq!(
234            rescale(48000, Rational::new(1, 48000), Rational::new(1, 1000)),
235            1000
236        );
237    }
238
239    #[test]
240    fn timestamp_seconds() {
241        let ts = Timestamp::new(48000, TimeBase::new(1, 48000));
242        assert!((ts.seconds() - 1.0).abs() < 1e-9);
243    }
244
245    #[test]
246    fn rescale_rounds_half_away_from_zero() {
247        // 1 tick at 1/2 s/tick → 1/1 base = 0.5 → ties up to 1.
248        assert_eq!(rescale(1, Rational::new(1, 2), Rational::new(1, 1)), 1);
249        // -1 tick at 1/2 s/tick → -0.5 → ties to -1 (away from zero).
250        assert_eq!(rescale(-1, Rational::new(1, 2), Rational::new(1, 1)), -1);
251        // 3 ticks at 1/2 → 1.5 → 2.
252        assert_eq!(rescale(3, Rational::new(1, 2), Rational::new(1, 1)), 2);
253        assert_eq!(rescale(-3, Rational::new(1, 2), Rational::new(1, 1)), -2);
254    }
255
256    #[test]
257    fn from_rate_matches_long_form() {
258        assert_eq!(TimeBase::from_rate(48_000), TimeBase::new(1, 48_000));
259        assert_eq!(TimeBase::from_rate(90_000), TimeBase::new(1, 90_000));
260        assert_eq!(TimeBase::from_rate(1), TimeBase::new(1, 1));
261    }
262
263    #[test]
264    fn num_den_accessors() {
265        let tb = TimeBase::new(1, 90_000);
266        assert_eq!(tb.num(), 1);
267        assert_eq!(tb.den(), 90_000);
268        // Const-context callable.
269        const NUM: i64 = TimeBase::AUDIO_48K.num();
270        const DEN: i64 = TimeBase::AUDIO_48K.den();
271        assert_eq!(NUM, 1);
272        assert_eq!(DEN, 48_000);
273    }
274
275    #[test]
276    fn is_valid_rejects_zero_terms() {
277        assert!(TimeBase::new(1, 1000).is_valid());
278        // Den == 0: undefined rate.
279        assert!(!TimeBase::new(1, 0).is_valid());
280        // Num == 0: degenerate ratio (everything is zero seconds).
281        assert!(!TimeBase::new(0, 1).is_valid());
282    }
283
284    #[test]
285    fn ticks_of_is_inverse_of_seconds_of() {
286        // 1 second on a 1/48000 base = 48000 ticks.
287        assert_eq!(TimeBase::AUDIO_48K.ticks_of(1.0), 48_000);
288        // 1 second on a 1/90000 base = 90000 ticks.
289        assert_eq!(TimeBase::MPEG_TS.ticks_of(1.0), 90_000);
290        // 0.5 second on 1/1000 base = 500 ticks.
291        assert_eq!(TimeBase::MILLIS.ticks_of(0.5), 500);
292        // Round-trip on integer multiples.
293        let tb = TimeBase::AUDIO_44K1;
294        assert_eq!(tb.ticks_of(tb.seconds_of(44_100)), 44_100);
295    }
296
297    #[test]
298    fn ticks_of_rounds_half_away_from_zero() {
299        // 0.5 tick on 1/1 base → 1 (positive ties up).
300        assert_eq!(TimeBase::SECONDS.ticks_of(0.5), 1);
301        // -0.5 tick on 1/1 base → -1 (negative ties down).
302        assert_eq!(TimeBase::SECONDS.ticks_of(-0.5), -1);
303        // 1.5 ticks → 2.
304        assert_eq!(TimeBase::SECONDS.ticks_of(1.5), 2);
305        // -1.5 ticks → -2.
306        assert_eq!(TimeBase::SECONDS.ticks_of(-1.5), -2);
307    }
308
309    #[test]
310    fn ticks_of_invalid_inputs() {
311        // Invalid time base → 0.
312        assert_eq!(TimeBase::new(1, 0).ticks_of(1.0), 0);
313        assert_eq!(TimeBase::new(0, 1).ticks_of(1.0), 0);
314        // Non-finite seconds → 0.
315        assert_eq!(TimeBase::MILLIS.ticks_of(f64::NAN), 0);
316        assert_eq!(TimeBase::MILLIS.ticks_of(f64::INFINITY), 0);
317        assert_eq!(TimeBase::MILLIS.ticks_of(f64::NEG_INFINITY), 0);
318    }
319
320    #[test]
321    fn common_constants_match_long_form() {
322        assert_eq!(TimeBase::SECONDS, TimeBase::new(1, 1));
323        assert_eq!(TimeBase::MILLIS, TimeBase::new(1, 1_000));
324        assert_eq!(TimeBase::MICROS, TimeBase::new(1, 1_000_000));
325        assert_eq!(TimeBase::NANOS, TimeBase::new(1, 1_000_000_000));
326        assert_eq!(TimeBase::MPEG_TS, TimeBase::new(1, 90_000));
327        assert_eq!(TimeBase::AUDIO_48K, TimeBase::new(1, 48_000));
328        assert_eq!(TimeBase::AUDIO_44K1, TimeBase::new(1, 44_100));
329        assert_eq!(TimeBase::AUDIO_8K, TimeBase::new(1, 8_000));
330    }
331
332    #[test]
333    fn timestamp_from_seconds() {
334        let ts = Timestamp::from_seconds(1.0, TimeBase::AUDIO_48K);
335        assert_eq!(ts.value, 48_000);
336        assert_eq!(ts.base, TimeBase::AUDIO_48K);
337        // Round-trip.
338        assert!((ts.seconds() - 1.0).abs() < 1e-9);
339    }
340
341    #[test]
342    fn checked_add_sub_ticks_round_trip() {
343        let ts = Timestamp::new(100, TimeBase::MILLIS);
344        assert_eq!(ts.checked_add_ticks(50).unwrap().value, 150);
345        assert_eq!(ts.checked_sub_ticks(50).unwrap().value, 50);
346        // Base unchanged through the arithmetic.
347        assert_eq!(ts.checked_add_ticks(50).unwrap().base, TimeBase::MILLIS);
348    }
349
350    #[test]
351    fn checked_add_ticks_detects_overflow() {
352        let ts = Timestamp::new(i64::MAX - 5, TimeBase::SECONDS);
353        assert!(ts.checked_add_ticks(10).is_none());
354        // Boundary case: i64::MAX exactly is fine.
355        let near_max = Timestamp::new(i64::MAX - 1, TimeBase::SECONDS);
356        assert_eq!(near_max.checked_add_ticks(1).unwrap().value, i64::MAX);
357    }
358
359    #[test]
360    fn checked_sub_ticks_detects_overflow() {
361        let ts = Timestamp::new(i64::MIN + 5, TimeBase::SECONDS);
362        assert!(ts.checked_sub_ticks(10).is_none());
363    }
364
365    #[test]
366    fn checked_diff_rescales_other_onto_self_base() {
367        // 1 second at 1/48000 minus 500ms at 1/1000 = 500ms = 24000 ticks at 48k.
368        let a = Timestamp::new(48_000, TimeBase::AUDIO_48K); // 1.0s
369        let b = Timestamp::new(500, TimeBase::MILLIS); // 0.5s
370        assert_eq!(a.checked_diff(b), Some(24_000));
371    }
372
373    #[test]
374    fn checked_diff_same_base() {
375        let a = Timestamp::new(1000, TimeBase::MILLIS);
376        let b = Timestamp::new(250, TimeBase::MILLIS);
377        assert_eq!(a.checked_diff(b), Some(750));
378        assert_eq!(b.checked_diff(a), Some(-750));
379    }
380}