flexaudio_core/clock.rs
1//! Helpers for translating timestamps to process-monotonic `i64` nanoseconds.
2//!
3//! Built-in microphone and loopback backends on every OS pass monotonic arrival times
4//! to `RawSink::push`, which discards them: the raw ring carries samples only. The
5//! facade assigns a fresh arrival time at ingest and translates it with
6//! [`ClockNormalizer`]. Device timestamps are not used by the capture pipeline;
7//! these labels do not provide media-clock or cross-stream synchronization.
8
9use std::time::Instant;
10
11/// Returns the current process monotonic clock value in nanoseconds.
12///
13/// A monotonic value based on [`Instant`]. It is not an absolute wall-clock time and
14/// is meaningful only for differences and ordering within the same process.
15pub fn monotonic_now_ns() -> i64 {
16 monotonic_base().elapsed().as_nanos() as i64
17}
18
19/// Process-wide monotonic clock origin, initialized on first use.
20fn monotonic_base() -> Instant {
21 use std::sync::OnceLock;
22 static BASE: OnceLock<Instant> = OnceLock::new();
23 *BASE.get_or_init(Instant::now)
24}
25
26/// Translates input timestamps to the process-monotonic clock.
27///
28/// Records the input timestamp and monotonic clock origins on the first call.
29/// Later calls return `device_pts - device_origin + monotonic_origin`, preserving
30/// input deltas. Despite the parameter names, the facade supplies ingest arrival
31/// times ([`monotonic_now_ns`]) for both microphone and loopback, not device PTS.
32#[derive(Debug, Clone)]
33pub struct ClockNormalizer {
34 /// Input timestamp origin recorded on the first call (ns).
35 device_origin_ns: Option<i64>,
36 /// Monotonic clock origin recorded on the first sample (ns).
37 monotonic_origin_ns: i64,
38}
39
40impl ClockNormalizer {
41 /// Creates a normalizer with unset origins, which are fixed by the first [`normalize`](Self::normalize).
42 pub fn new() -> Self {
43 Self {
44 device_origin_ns: None,
45 monotonic_origin_ns: 0,
46 }
47 }
48
49 /// Returns whether the origins are still unset (the first sample has not arrived).
50 pub fn is_unset(&self) -> bool {
51 self.device_origin_ns.is_none()
52 }
53
54 /// Maps an input timestamp (ns) to the process-monotonic timeline (ns).
55 ///
56 /// The first call fixes the origins and uses the current [`monotonic_now_ns`] as
57 /// the monotonic origin. Later calls preserve input timestamp deltas.
58 /// Input timestamp rollover uses wrapping subtraction; a translated value outside i64
59 /// saturates at the nearest boundary rather than panicking or wrapping the timeline.
60 pub fn normalize(&mut self, device_pts_ns: i64) -> i64 {
61 match self.device_origin_ns {
62 Some(origin) => self
63 .monotonic_origin_ns
64 .saturating_add(device_pts_ns.wrapping_sub(origin)),
65 None => {
66 self.device_origin_ns = Some(device_pts_ns);
67 self.monotonic_origin_ns = monotonic_now_ns();
68 self.monotonic_origin_ns
69 }
70 }
71 }
72}
73
74impl Default for ClockNormalizer {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn monotonic_is_non_decreasing() {
86 let a = monotonic_now_ns();
87 let b = monotonic_now_ns();
88 assert!(b >= a);
89 }
90
91 #[test]
92 fn first_sample_sets_origin_and_preserves_deltas() {
93 let mut n = ClockNormalizer::new();
94 assert!(n.is_unset());
95 // On the first call, fix the device origin and return the monotonic origin.
96 let t0 = n.normalize(1_000_000); // device pts 1ms
97 assert!(!n.is_unset());
98 // Preserve device deltas: +5ms produces a normalized value that is also +5ms.
99 let t1 = n.normalize(6_000_000);
100 assert_eq!(t1 - t0, 5_000_000);
101 // Preserve backward movement as a negative delta for gap detection by callers.
102 let t2 = n.normalize(4_000_000);
103 assert_eq!(t2 - t0, 3_000_000);
104 }
105
106 /// `normalize` computes deltas with `wrapping_sub`. If `device_pts` overflows near
107 /// `i64::MAX`, it does not panic; the wrapped delta is added to the monotonic origin.
108 /// Verify that +1 from origin `i64::MAX` wraps to `i64::MIN`, and `wrapping_sub`
109 /// yields `i64::MIN - i64::MAX = +1` (wrapped).
110 #[test]
111 fn normalize_wrapping_sub_handles_i64_boundary() {
112 let mut n = ClockNormalizer::new();
113 // Fix the origin at i64::MAX (the monotonic origin is the current monotonic_now_ns).
114 let base = n.normalize(i64::MAX);
115 // Set device_pts to i64::MIN to simulate clock overflow.
116 // wrapping_sub(i64::MAX) wraps to +1 (i64::MIN - i64::MAX = 1 mod 2^64).
117 let next = n.normalize(i64::MIN);
118 assert_eq!(
119 next.wrapping_sub(base),
120 1,
121 "wrapping_sub boundary: MIN - MAX should wrap to +1"
122 );
123 }
124
125 /// Verify that the first normalize changes `is_unset` to false on a fresh normalizer.
126 /// Even for a large negative device_pts, the first call returns the current monotonic
127 /// origin without calculating a delta.
128 #[test]
129 fn first_normalize_ignores_device_value_for_origin() {
130 let mut n = ClockNormalizer::new();
131 // The first call returns monotonic_now_ns regardless of the device value; it only fixes the origins.
132 let before = monotonic_now_ns();
133 let t0 = n.normalize(i64::MIN);
134 let after = monotonic_now_ns();
135 assert!(
136 t0 >= before && t0 <= after,
137 "first call should return the monotonic origin: {before} <= {t0} <= {after}"
138 );
139 assert!(!n.is_unset());
140 }
141
142 /// `Default` creates the same unset state as `new`.
143 #[test]
144 fn default_is_unset_like_new() {
145 let n = ClockNormalizer::default();
146 assert!(n.is_unset());
147 }
148}
149
150#[cfg(test)]
151mod saturation_tests {
152 use super::*;
153 #[test]
154 fn translated_clock_saturates_instead_of_panicking_or_wrapping() {
155 let mut clock = ClockNormalizer {
156 device_origin_ns: Some(0),
157 monotonic_origin_ns: 10,
158 };
159 assert_eq!(clock.normalize(i64::MAX), i64::MAX);
160 clock.monotonic_origin_ns = -10;
161 assert_eq!(clock.normalize(i64::MIN), i64::MIN);
162 }
163}