Skip to main content

flexaudio_core/
clock.rs

1//! Helpers for normalizing device PTS and monotonic clocks to shared `i64` nanoseconds.
2//!
3//! Loopback sources have device-clock PTS, while microphone (cpal) sources have only
4//! relative, opaque timestamps. The core maps both to a shared monotonic clock using
5//! the origin offset captured when the source opens. Cross-stream synchronization is
6//! best effort.
7
8use std::time::Instant;
9
10/// Returns the current process monotonic clock value in nanoseconds.
11///
12/// A monotonic value based on [`Instant`]. It is not an absolute wall-clock time and
13/// is meaningful only for differences and ordering within the same process.
14pub fn monotonic_now_ns() -> i64 {
15    monotonic_base().elapsed().as_nanos() as i64
16}
17
18/// Monotonic clock origin, fixed once when the process starts.
19fn monotonic_base() -> Instant {
20    use std::sync::OnceLock;
21    static BASE: OnceLock<Instant> = OnceLock::new();
22    *BASE.get_or_init(Instant::now)
23}
24
25/// Normalizes device PTS to the shared monotonic clock.
26///
27/// Records the device PTS origin and monotonic clock origin on the first sample.
28/// Later calls return `device_pts - device_origin + monotonic_origin`, shifting the
29/// device clock onto the monotonic timeline while preserving its rate.
30///
31/// For sources without device PTS, such as microphones, pass each sample's arrival
32/// time ([`monotonic_now_ns`]) as `device_pts`.
33#[derive(Debug, Clone)]
34pub struct ClockNormalizer {
35    /// Device PTS origin recorded on the first sample (ns).
36    device_origin_ns: Option<i64>,
37    /// Monotonic clock origin recorded on the first sample (ns).
38    monotonic_origin_ns: i64,
39}
40
41impl ClockNormalizer {
42    /// Creates a normalizer with unset origins, which are fixed by the first [`normalize`](Self::normalize).
43    pub fn new() -> Self {
44        Self {
45            device_origin_ns: None,
46            monotonic_origin_ns: 0,
47        }
48    }
49
50    /// Returns whether the origins are still unset (the first sample has not arrived).
51    pub fn is_unset(&self) -> bool {
52        self.device_origin_ns.is_none()
53    }
54
55    /// Maps device PTS (ns) to normalized monotonic PTS (ns).
56    ///
57    /// The first call fixes the origins and uses the current [`monotonic_now_ns`] as
58    /// the monotonic origin. Later calls preserve device-clock deltas.
59    pub fn normalize(&mut self, device_pts_ns: i64) -> i64 {
60        match self.device_origin_ns {
61            Some(origin) => self.monotonic_origin_ns + device_pts_ns.wrapping_sub(origin),
62            None => {
63                self.device_origin_ns = Some(device_pts_ns);
64                self.monotonic_origin_ns = monotonic_now_ns();
65                self.monotonic_origin_ns
66            }
67        }
68    }
69}
70
71impl Default for ClockNormalizer {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn monotonic_is_non_decreasing() {
83        let a = monotonic_now_ns();
84        let b = monotonic_now_ns();
85        assert!(b >= a);
86    }
87
88    #[test]
89    fn first_sample_sets_origin_and_preserves_deltas() {
90        let mut n = ClockNormalizer::new();
91        assert!(n.is_unset());
92        // On the first call, fix the device origin and return the monotonic origin.
93        let t0 = n.normalize(1_000_000); // device pts 1ms
94        assert!(!n.is_unset());
95        // Preserve device deltas: +5ms produces a normalized value that is also +5ms.
96        let t1 = n.normalize(6_000_000);
97        assert_eq!(t1 - t0, 5_000_000);
98        // Preserve backward movement as a negative delta for gap detection by callers.
99        let t2 = n.normalize(4_000_000);
100        assert_eq!(t2 - t0, 3_000_000);
101    }
102
103    /// `normalize` computes deltas with `wrapping_sub`. If `device_pts` overflows near
104    /// `i64::MAX`, it does not panic; the wrapped delta is added to the monotonic origin.
105    /// Verify that +1 from origin `i64::MAX` wraps to `i64::MIN`, and `wrapping_sub`
106    /// yields `i64::MIN - i64::MAX = +1` (wrapped).
107    #[test]
108    fn normalize_wrapping_sub_handles_i64_boundary() {
109        let mut n = ClockNormalizer::new();
110        // Fix the origin at i64::MAX (the monotonic origin is the current monotonic_now_ns).
111        let base = n.normalize(i64::MAX);
112        // Set device_pts to i64::MIN to simulate clock overflow.
113        // wrapping_sub(i64::MAX) wraps to +1 (i64::MIN - i64::MAX = 1 mod 2^64).
114        let next = n.normalize(i64::MIN);
115        assert_eq!(
116            next.wrapping_sub(base),
117            1,
118            "wrapping_sub boundary: MIN - MAX should wrap to +1"
119        );
120    }
121
122    /// Verify that the first normalize changes `is_unset` to false on a fresh normalizer.
123    /// Even for a large negative device_pts, the first call returns the current monotonic
124    /// origin without calculating a delta.
125    #[test]
126    fn first_normalize_ignores_device_value_for_origin() {
127        let mut n = ClockNormalizer::new();
128        // The first call returns monotonic_now_ns regardless of the device value; it only fixes the origins.
129        let before = monotonic_now_ns();
130        let t0 = n.normalize(i64::MIN);
131        let after = monotonic_now_ns();
132        assert!(
133            t0 >= before && t0 <= after,
134            "first call should return the monotonic origin: {before} <= {t0} <= {after}"
135        );
136        assert!(!n.is_unset());
137    }
138
139    /// `Default` creates the same unset state as `new`.
140    #[test]
141    fn default_is_unset_like_new() {
142        let n = ClockNormalizer::default();
143        assert!(n.is_unset());
144    }
145}