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}