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}