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}