Skip to main content

media/
clock.rs

1//! Shared timeline — `MediaTime`, `MediaDuration`, `MediaClock`,
2//! `Timestamped<T>` (M-MEDIA.2 / AUT-98).
3//!
4//! Every audio chunk, video frame, cursor event, and visualization
5//! window in the recorder is stamped against a single timeline. This
6//! module is that timeline's vocabulary.
7//!
8//! # Why nanoseconds
9//!
10//! Internal representation is **`i64` nanoseconds** (signed so a
11//! pre-origin offset is representable). `i64::MAX` nanoseconds is
12//! ≈ 292 years — comfortable headroom for any recorder session.
13//! `f64` seconds drops below 1 µs precision past ~10⁹ s; nanoseconds
14//! stay exact through arithmetic.
15//!
16//! # Conversion helpers
17//!
18//! ```rust
19//! use media::clock::MediaTime;
20//!
21//! // 30 fps, frame 90 → 3.0 s.
22//! let t = MediaTime::from_frame(90, 30.0);
23//! assert!((t.as_seconds() - 3.0).abs() < 1e-9);
24//!
25//! // 48 kHz, sample 48 000 → 1.0 s.
26//! let t = MediaTime::from_sample(48_000, 48_000);
27//! assert!((t.as_seconds() - 1.0).abs() < 1e-9);
28//! ```
29//!
30//! # `MediaClock` modes
31//!
32//! - [`MediaClock::wall_clock`] — anchored to `Instant::now()`; production.
33//! - [`MediaClock::manual`] — driven by explicit [`MediaClock::advance_by`];
34//!   tests + headless examples that need byte-exact reproducibility.
35
36use std::sync::Mutex;
37use std::time::Instant;
38
39/// One point on the media timeline. Resolution is nanoseconds.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct MediaTime {
42    nanos: i64,
43}
44
45impl MediaTime {
46    /// Time zero — the timeline origin.
47    pub const ZERO: Self = Self { nanos: 0 };
48
49    /// Construct from raw nanoseconds since origin (can be negative).
50    #[must_use]
51    pub const fn from_nanos(nanos: i64) -> Self {
52        Self { nanos }
53    }
54
55    /// Construct from seconds since origin (`f64`).
56    #[must_use]
57    pub fn from_seconds(s: f64) -> Self {
58        Self {
59            nanos: nanos_from_seconds(s),
60        }
61    }
62
63    /// Construct from an audio sample index at a given sample rate.
64    ///
65    /// `MediaTime::from_sample(48_000, 48_000) == 1 s`.
66    #[must_use]
67    pub fn from_sample(index: u64, sample_rate: u32) -> Self {
68        assert!(sample_rate > 0, "sample_rate must be > 0");
69        // nanos = index * 1e9 / sample_rate, all in integer space to
70        // avoid f64 precision loss for large sample counts.
71        let numerator = u128::from(index) * 1_000_000_000_u128;
72        let nanos = (numerator / u128::from(sample_rate))
73            .try_into()
74            .expect("sample timestamp overflows i64 nanoseconds");
75        Self { nanos }
76    }
77
78    /// Construct from a video frame index at a given frame rate (fps).
79    ///
80    /// `MediaTime::from_frame(90, 30.0) == 3 s`.
81    #[must_use]
82    pub fn from_frame(index: u64, frame_rate: f64) -> Self {
83        assert!(
84            frame_rate.is_finite() && frame_rate > 0.0,
85            "frame_rate must be finite and > 0"
86        );
87        Self::from_seconds(index_as_f64(index) / frame_rate)
88    }
89
90    /// Raw nanoseconds since origin.
91    #[must_use]
92    pub const fn as_nanos(self) -> i64 {
93        self.nanos
94    }
95
96    /// Seconds since origin (`f64`).
97    #[must_use]
98    pub fn as_seconds(self) -> f64 {
99        // i64 nanos → f64 seconds. Past ~9.2e15 ns (~106 days) the
100        // f64 mantissa starts losing per-ns precision; arithmetic
101        // should prefer nanoseconds for accuracy.
102        #[expect(
103            clippy::cast_precision_loss,
104            reason = "f64 covers all reasonable session lengths; clients wanting exact nanos use as_nanos()"
105        )]
106        let secs = self.nanos as f64 / 1.0e9;
107        secs
108    }
109
110    /// Audio sample index at a given rate, rounded to nearest.
111    ///
112    /// Rounding (vs truncation) keeps `from_sample` ↔ `to_sample`
113    /// exact round-trip for any sample rate that doesn't divide 10⁹
114    /// evenly (e.g. 44_100 Hz produces non-integer nanoseconds per
115    /// sample; truncating would drift -1 every sample).
116    ///
117    /// `MediaTime::from_seconds(1.0).to_sample(48_000) == 48_000`.
118    #[must_use]
119    pub fn to_sample(self, sample_rate: u32) -> i64 {
120        assert!(sample_rate > 0, "sample_rate must be > 0");
121        // Round-half-toward-positive-infinity: `(x + N/2) / N` for
122        // positive x. For negative x we mirror the sign on the offset
123        // so round-trip stays exact symmetrically.
124        let product = i128::from(self.nanos) * i128::from(sample_rate);
125        let half: i128 = 500_000_000;
126        let biased = if product >= 0 {
127            product + half
128        } else {
129            product - half
130        };
131        (biased / 1_000_000_000_i128)
132            .try_into()
133            .expect("sample index overflows i64")
134    }
135
136    /// Video frame index at a given fps, rounded to nearest.
137    ///
138    /// Same round-trip rationale as [`Self::to_sample`].
139    #[must_use]
140    pub fn to_frame(self, frame_rate: f64) -> i64 {
141        assert!(
142            frame_rate.is_finite() && frame_rate > 0.0,
143            "frame_rate must be finite and > 0"
144        );
145        let seconds = self.as_seconds();
146        #[expect(
147            clippy::cast_possible_truncation,
148            reason = "frame index from valid timestamp + fps fits in i64 for any reasonable session"
149        )]
150        let idx = (seconds * frame_rate).round() as i64;
151        idx
152    }
153}
154
155impl std::ops::Add<MediaDuration> for MediaTime {
156    type Output = Self;
157    fn add(self, rhs: MediaDuration) -> Self {
158        Self {
159            nanos: self.nanos.saturating_add(rhs.nanos),
160        }
161    }
162}
163
164impl std::ops::Sub<MediaDuration> for MediaTime {
165    type Output = Self;
166    fn sub(self, rhs: MediaDuration) -> Self {
167        Self {
168            nanos: self.nanos.saturating_sub(rhs.nanos),
169        }
170    }
171}
172
173impl std::ops::Sub<MediaTime> for MediaTime {
174    type Output = MediaDuration;
175    /// `b - a` returns the `MediaDuration` between two times.
176    fn sub(self, rhs: MediaTime) -> MediaDuration {
177        MediaDuration {
178            nanos: self.nanos.saturating_sub(rhs.nanos),
179        }
180    }
181}
182
183/// An interval between two `MediaTime` values. Resolution is
184/// nanoseconds. Can be negative (e.g., when measuring drift).
185#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
186pub struct MediaDuration {
187    nanos: i64,
188}
189
190impl MediaDuration {
191    /// Zero duration.
192    pub const ZERO: Self = Self { nanos: 0 };
193
194    /// Construct from raw nanoseconds.
195    #[must_use]
196    pub const fn from_nanos(nanos: i64) -> Self {
197        Self { nanos }
198    }
199
200    /// Construct from seconds.
201    #[must_use]
202    pub fn from_seconds(s: f64) -> Self {
203        Self {
204            nanos: nanos_from_seconds(s),
205        }
206    }
207
208    /// Construct from milliseconds.
209    #[must_use]
210    pub const fn from_millis(ms: i64) -> Self {
211        Self {
212            nanos: ms.saturating_mul(1_000_000),
213        }
214    }
215
216    /// Raw nanoseconds.
217    #[must_use]
218    pub const fn as_nanos(self) -> i64 {
219        self.nanos
220    }
221
222    /// Seconds (`f64`).
223    #[must_use]
224    pub fn as_seconds(self) -> f64 {
225        #[expect(
226            clippy::cast_precision_loss,
227            reason = "callers wanting exact nanos use as_nanos()"
228        )]
229        let secs = self.nanos as f64 / 1.0e9;
230        secs
231    }
232
233    /// Absolute value — useful when measuring drift.
234    #[must_use]
235    pub const fn abs(self) -> Self {
236        Self {
237            nanos: self.nanos.abs(),
238        }
239    }
240}
241
242impl std::ops::Add<MediaDuration> for MediaDuration {
243    type Output = Self;
244    fn add(self, rhs: Self) -> Self {
245        Self {
246            nanos: self.nanos.saturating_add(rhs.nanos),
247        }
248    }
249}
250
251impl std::ops::Sub<MediaDuration> for MediaDuration {
252    type Output = Self;
253    fn sub(self, rhs: Self) -> Self {
254        Self {
255            nanos: self.nanos.saturating_sub(rhs.nanos),
256        }
257    }
258}
259
260/// A value carried with its position on the media timeline.
261///
262/// Used wherever an audio chunk, video frame, or cursor event flows
263/// through the system: `Timestamped<AudioChunk>`, `Timestamped<VideoFrame>`,
264/// `Timestamped<CursorEvent>`.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
266pub struct Timestamped<T> {
267    /// When the value occurred on the media timeline.
268    pub time: MediaTime,
269    /// The carried value.
270    pub value: T,
271}
272
273impl<T> Timestamped<T> {
274    /// Construct a timestamped value.
275    pub const fn new(time: MediaTime, value: T) -> Self {
276        Self { time, value }
277    }
278
279    /// Borrow the inner value.
280    pub const fn as_ref(&self) -> Timestamped<&T> {
281        Timestamped {
282            time: self.time,
283            value: &self.value,
284        }
285    }
286}
287
288/// Authoritative timeline source. Capture pipelines stamp their
289/// chunks/frames against a single shared `MediaClock` so audio, video,
290/// cursor, and `wisp` overlays all agree on "now."
291///
292/// Two modes — pick at construction:
293///
294/// - [`MediaClock::wall_clock`] — anchored to `Instant::now()`. Used
295///   by live capture and playback.
296/// - [`MediaClock::manual`] — driven by [`MediaClock::advance_by`].
297///   Used by tests, headless examples, and the synthetic sync harness
298///   (M-MEDIA.7) so timestamps are byte-exact reproducible.
299#[derive(Debug)]
300pub struct MediaClock {
301    inner: ClockInner,
302}
303
304#[derive(Debug)]
305enum ClockInner {
306    Wall { origin: Instant },
307    Manual { current: Mutex<MediaTime> },
308}
309
310impl Default for MediaClock {
311    fn default() -> Self {
312        Self::wall_clock()
313    }
314}
315
316impl MediaClock {
317    /// Construct a wall-clock-backed `MediaClock`, anchored to *now*.
318    /// Subsequent `now()` calls return `MediaTime` values relative to
319    /// this anchor.
320    #[must_use]
321    pub fn wall_clock() -> Self {
322        Self {
323            inner: ClockInner::Wall {
324                origin: Instant::now(),
325            },
326        }
327    }
328
329    /// Construct a manual clock starting at `start`. The clock only
330    /// advances when [`Self::advance_by`] is called — useful for
331    /// deterministic tests.
332    #[must_use]
333    pub fn manual(start: MediaTime) -> Self {
334        Self {
335            inner: ClockInner::Manual {
336                current: Mutex::new(start),
337            },
338        }
339    }
340
341    /// Current timeline position.
342    #[must_use]
343    pub fn now(&self) -> MediaTime {
344        match &self.inner {
345            ClockInner::Wall { origin } => {
346                let elapsed = origin.elapsed();
347                let nanos = i64::try_from(elapsed.as_nanos())
348                    .expect("wall-clock elapsed overflows i64 ns (session > 292y)");
349                MediaTime::from_nanos(nanos)
350            }
351            ClockInner::Manual { current } => *current.lock().expect("clock poisoned"),
352        }
353    }
354
355    /// Advance a manual clock by `duration`. No-op on a wall clock.
356    pub fn advance_by(&self, duration: MediaDuration) {
357        if let ClockInner::Manual { current } = &self.inner {
358            let mut t = current.lock().expect("clock poisoned");
359            *t = *t + duration;
360        }
361    }
362
363    /// Attach the current timestamp to `value`.
364    pub fn assign<T>(&self, value: T) -> Timestamped<T> {
365        Timestamped::new(self.now(), value)
366    }
367
368    /// True for a manual clock; false for a wall clock. Used by tests
369    /// and examples to gate assertions on byte-exact timestamps.
370    #[must_use]
371    pub const fn is_manual(&self) -> bool {
372        matches!(self.inner, ClockInner::Manual { .. })
373    }
374}
375
376fn nanos_from_seconds(s: f64) -> i64 {
377    assert!(s.is_finite(), "seconds must be finite");
378    #[expect(
379        clippy::cast_possible_truncation,
380        reason = "i64 nanos covers ±292y — any realistic recorder session"
381    )]
382    let nanos = (s * 1.0e9).round() as i64;
383    nanos
384}
385
386fn index_as_f64(index: u64) -> f64 {
387    #[expect(
388        clippy::cast_precision_loss,
389        reason = "frame indices above 2^53 (≈ 9.4e15) are not realistic for recorder sessions"
390    )]
391    let n = index as f64;
392    n
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn frame_90_at_30fps_equals_three_seconds() {
401        let t = MediaTime::from_frame(90, 30.0);
402        assert!(
403            (t.as_seconds() - 3.0).abs() < 1e-9,
404            "got {} s",
405            t.as_seconds()
406        );
407    }
408
409    #[test]
410    fn sample_48000_at_48khz_equals_one_second() {
411        let t = MediaTime::from_sample(48_000, 48_000);
412        assert!(
413            (t.as_seconds() - 1.0).abs() < 1e-12,
414            "got {} s ({} ns)",
415            t.as_seconds(),
416            t.as_nanos()
417        );
418        // Exact in integer space.
419        assert_eq!(t.as_nanos(), 1_000_000_000);
420    }
421
422    #[test]
423    fn sample_round_trip_is_exact() {
424        // Pick a few sample indices and confirm to_sample inverts from_sample.
425        for (idx, rate) in [
426            (0_u64, 48_000_u32),
427            (1, 44_100),
428            (48_000, 48_000),
429            (1_234_567, 48_000),
430        ] {
431            let t = MediaTime::from_sample(idx, rate);
432            assert_eq!(
433                t.to_sample(rate),
434                i64::try_from(idx).unwrap(),
435                "round-trip failed for idx={idx} rate={rate}"
436            );
437        }
438    }
439
440    #[test]
441    fn frame_round_trip_is_exact_for_common_rates() {
442        for (idx, fps) in [(0_u64, 30.0), (1, 30.0), (90, 30.0), (60, 60.0), (24, 24.0)] {
443            let t = MediaTime::from_frame(idx, fps);
444            assert_eq!(
445                t.to_frame(fps),
446                i64::try_from(idx).unwrap(),
447                "round-trip failed for idx={idx} fps={fps}"
448            );
449        }
450    }
451
452    #[test]
453    fn duration_arith_seconds() {
454        let a = MediaDuration::from_seconds(1.5);
455        let b = MediaDuration::from_seconds(0.5);
456        assert!((((a + b).as_seconds()) - 2.0).abs() < 1e-9);
457        assert!((((a - b).as_seconds()) - 1.0).abs() < 1e-9);
458    }
459
460    #[test]
461    fn duration_arith_via_media_time() {
462        let t0 = MediaTime::from_seconds(1.0);
463        let t1 = MediaTime::from_seconds(4.25);
464        let d = t1 - t0;
465        assert!((d.as_seconds() - 3.25).abs() < 1e-9);
466        assert_eq!((t0 + d), t1);
467    }
468
469    #[test]
470    fn media_time_is_monotonically_ordered() {
471        let a = MediaTime::from_seconds(1.0);
472        let b = MediaTime::from_seconds(1.000_000_001);
473        let c = MediaTime::from_seconds(2.0);
474        assert!(a < b);
475        assert!(b < c);
476        assert!(a < c);
477        let mut v = vec![c, a, b];
478        v.sort();
479        assert_eq!(v, vec![a, b, c]);
480    }
481
482    #[test]
483    fn manual_clock_only_advances_when_told() {
484        let c = MediaClock::manual(MediaTime::ZERO);
485        assert_eq!(c.now(), MediaTime::ZERO);
486        c.advance_by(MediaDuration::from_seconds(0.5));
487        assert!((c.now().as_seconds() - 0.5).abs() < 1e-12);
488        c.advance_by(MediaDuration::from_seconds(0.5));
489        assert!((c.now().as_seconds() - 1.0).abs() < 1e-12);
490        assert!(c.is_manual());
491    }
492
493    #[test]
494    fn wall_clock_is_monotonically_non_decreasing() {
495        let c = MediaClock::wall_clock();
496        let mut last = c.now();
497        for _ in 0..10 {
498            let t = c.now();
499            assert!(t >= last, "wall clock went backwards");
500            last = t;
501        }
502        assert!(!c.is_manual());
503    }
504
505    #[test]
506    fn timestamped_carries_value() {
507        let c = MediaClock::manual(MediaTime::from_seconds(2.5));
508        let ts: Timestamped<&str> = c.assign("hello");
509        assert_eq!(ts.value, "hello");
510        assert!((ts.time.as_seconds() - 2.5).abs() < 1e-12);
511    }
512
513    #[test]
514    fn duration_abs_for_drift_reporting() {
515        let d = MediaDuration::from_seconds(-0.03);
516        assert!((d.abs().as_seconds() - 0.03).abs() < 1e-9);
517    }
518
519    #[test]
520    fn duration_from_millis_is_exact() {
521        let d = MediaDuration::from_millis(20);
522        assert_eq!(d.as_nanos(), 20_000_000);
523    }
524
525    #[test]
526    fn types_are_send_and_sync() {
527        fn assert_send_sync<T: Send + Sync>() {}
528        assert_send_sync::<MediaTime>();
529        assert_send_sync::<MediaDuration>();
530        assert_send_sync::<MediaClock>();
531        assert_send_sync::<Timestamped<u32>>();
532    }
533}