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    /// Device rollover uses wrapping subtraction; a translated value outside i64
60    /// saturates at the nearest boundary rather than panicking or wrapping the timeline.
61    pub fn normalize(&mut self, device_pts_ns: i64) -> i64 {
62        match self.device_origin_ns {
63            Some(origin) => self
64                .monotonic_origin_ns
65                .saturating_add(device_pts_ns.wrapping_sub(origin)),
66            None => {
67                self.device_origin_ns = Some(device_pts_ns);
68                self.monotonic_origin_ns = monotonic_now_ns();
69                self.monotonic_origin_ns
70            }
71        }
72    }
73}
74
75impl Default for ClockNormalizer {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn monotonic_is_non_decreasing() {
87        let a = monotonic_now_ns();
88        let b = monotonic_now_ns();
89        assert!(b >= a);
90    }
91
92    #[test]
93    fn first_sample_sets_origin_and_preserves_deltas() {
94        let mut n = ClockNormalizer::new();
95        assert!(n.is_unset());
96        // On the first call, fix the device origin and return the monotonic origin.
97        let t0 = n.normalize(1_000_000); // device pts 1ms
98        assert!(!n.is_unset());
99        // Preserve device deltas: +5ms produces a normalized value that is also +5ms.
100        let t1 = n.normalize(6_000_000);
101        assert_eq!(t1 - t0, 5_000_000);
102        // Preserve backward movement as a negative delta for gap detection by callers.
103        let t2 = n.normalize(4_000_000);
104        assert_eq!(t2 - t0, 3_000_000);
105    }
106
107    /// `normalize` computes deltas with `wrapping_sub`. If `device_pts` overflows near
108    /// `i64::MAX`, it does not panic; the wrapped delta is added to the monotonic origin.
109    /// Verify that +1 from origin `i64::MAX` wraps to `i64::MIN`, and `wrapping_sub`
110    /// yields `i64::MIN - i64::MAX = +1` (wrapped).
111    #[test]
112    fn normalize_wrapping_sub_handles_i64_boundary() {
113        let mut n = ClockNormalizer::new();
114        // Fix the origin at i64::MAX (the monotonic origin is the current monotonic_now_ns).
115        let base = n.normalize(i64::MAX);
116        // Set device_pts to i64::MIN to simulate clock overflow.
117        // wrapping_sub(i64::MAX) wraps to +1 (i64::MIN - i64::MAX = 1 mod 2^64).
118        let next = n.normalize(i64::MIN);
119        assert_eq!(
120            next.wrapping_sub(base),
121            1,
122            "wrapping_sub boundary: MIN - MAX should wrap to +1"
123        );
124    }
125
126    /// Verify that the first normalize changes `is_unset` to false on a fresh normalizer.
127    /// Even for a large negative device_pts, the first call returns the current monotonic
128    /// origin without calculating a delta.
129    #[test]
130    fn first_normalize_ignores_device_value_for_origin() {
131        let mut n = ClockNormalizer::new();
132        // The first call returns monotonic_now_ns regardless of the device value; it only fixes the origins.
133        let before = monotonic_now_ns();
134        let t0 = n.normalize(i64::MIN);
135        let after = monotonic_now_ns();
136        assert!(
137            t0 >= before && t0 <= after,
138            "first call should return the monotonic origin: {before} <= {t0} <= {after}"
139        );
140        assert!(!n.is_unset());
141    }
142
143    /// `Default` creates the same unset state as `new`.
144    #[test]
145    fn default_is_unset_like_new() {
146        let n = ClockNormalizer::default();
147        assert!(n.is_unset());
148    }
149}
150
151#[cfg(test)]
152mod saturation_tests {
153    use super::*;
154    #[test]
155    fn translated_clock_saturates_instead_of_panicking_or_wrapping() {
156        let mut clock = ClockNormalizer {
157            device_origin_ns: Some(0),
158            monotonic_origin_ns: 10,
159        };
160        assert_eq!(clock.normalize(i64::MAX), i64::MAX);
161        clock.monotonic_origin_ns = -10;
162        assert_eq!(clock.normalize(i64::MIN), i64::MIN);
163    }
164}