bbx_net/
clock.rs

1//! Clock synchronization for distributed audio timing.
2//!
3//! Provides `SyncedTimestamp` for representing synchronized time across nodes
4//! and `ClockSync` for managing clock synchronization state.
5
6use std::{
7    sync::atomic::{AtomicU64, Ordering},
8    time::Instant,
9};
10
11/// A synchronized timestamp in microseconds since server start.
12///
13/// Used for scheduling events across distributed nodes with sample-accurate
14/// timing within audio buffers.
15#[repr(C)]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
17pub struct SyncedTimestamp(pub u64);
18
19impl SyncedTimestamp {
20    /// Create a timestamp from microseconds.
21    pub const fn from_micros(micros: u64) -> Self {
22        Self(micros)
23    }
24
25    /// Get the timestamp value in microseconds.
26    pub const fn as_micros(&self) -> u64 {
27        self.0
28    }
29
30    /// Convert to sample offset within a buffer.
31    ///
32    /// Returns `Some(offset)` if the timestamp falls within the current buffer,
33    /// `None` if it's in a future buffer. Events in the past return offset 0.
34    ///
35    /// # Arguments
36    ///
37    /// * `buffer_start_time` - Timestamp at the start of the current buffer
38    /// * `sample_rate` - Audio sample rate in Hz
39    /// * `buffer_size` - Number of samples in the buffer
40    pub fn to_sample_offset(
41        &self,
42        buffer_start_time: SyncedTimestamp,
43        sample_rate: f64,
44        buffer_size: usize,
45    ) -> Option<u32> {
46        if self.0 < buffer_start_time.0 {
47            return Some(0);
48        }
49
50        let delta_micros = self.0 - buffer_start_time.0;
51        let delta_seconds = delta_micros as f64 / 1_000_000.0;
52        let sample_offset = (delta_seconds * sample_rate) as usize;
53
54        if sample_offset < buffer_size {
55            Some(sample_offset as u32)
56        } else {
57            None
58        }
59    }
60
61    /// Calculate the difference between two timestamps in microseconds.
62    pub fn delta(&self, other: SyncedTimestamp) -> i64 {
63        self.0 as i64 - other.0 as i64
64    }
65}
66
67/// Clock synchronization state for a node.
68///
69/// Manages the mapping between local time and synchronized network time.
70/// Thread-safe for reading the current time from the audio thread.
71pub struct ClockSync {
72    start_instant: Instant,
73    current_time: AtomicU64,
74}
75
76impl ClockSync {
77    /// Create a new clock synchronization instance.
78    pub fn new() -> Self {
79        Self {
80            start_instant: Instant::now(),
81            current_time: AtomicU64::new(0),
82        }
83    }
84
85    /// Get the current synchronized timestamp.
86    ///
87    /// This reads the system clock and converts to microseconds since start.
88    ///
89    /// # Realtime Safety
90    ///
91    /// This method is NOT realtime-safe as it calls `Instant::elapsed()` which
92    /// may invoke a system call. For audio thread use, call [`tick()`](Self::tick)
93    /// from a non-audio thread and use [`cached_now()`](Self::cached_now) from
94    /// the audio thread.
95    #[inline]
96    pub fn now(&self) -> SyncedTimestamp {
97        let elapsed = self.start_instant.elapsed();
98        SyncedTimestamp(elapsed.as_micros() as u64)
99    }
100
101    /// Update the cached current time.
102    ///
103    /// Call this periodically (e.g., at the start of each audio buffer)
104    /// to update the cached time value.
105    ///
106    /// # Realtime Safety
107    ///
108    /// This method is NOT realtime-safe as it calls [`now()`](Self::now)
109    /// internally. Call this from your main thread or audio device callback
110    /// thread, then use [`cached_now()`](Self::cached_now) from the audio
111    /// processing code.
112    pub fn tick(&self) {
113        let now = self.now();
114        self.current_time.store(now.0, Ordering::Relaxed);
115    }
116
117    /// Get the cached current time.
118    ///
119    /// Faster than `now()` as it avoids a system call, but may be slightly stale.
120    /// Use for non-critical timing within the audio thread.
121    #[inline]
122    pub fn cached_now(&self) -> SyncedTimestamp {
123        SyncedTimestamp(self.current_time.load(Ordering::Relaxed))
124    }
125
126    /// Get the time elapsed since clock creation in microseconds.
127    pub fn elapsed_micros(&self) -> u64 {
128        self.start_instant.elapsed().as_micros() as u64
129    }
130
131    /// Calculate client clock offset based on ping/pong exchange.
132    ///
133    /// Uses NTP-style offset calculation to determine the difference between
134    /// client and server clocks.
135    ///
136    /// # Arguments
137    ///
138    /// * `client_send_time` - Client timestamp when ping was sent
139    /// * `server_receive_time` - Server timestamp when ping was received
140    /// * `server_send_time` - Server timestamp when pong was sent
141    /// * `client_receive_time` - Client timestamp when pong was received
142    ///
143    /// # Returns
144    ///
145    /// Clock offset in microseconds (positive = client ahead, negative = client behind)
146    pub fn calculate_offset(
147        client_send_time: u64,
148        server_receive_time: u64,
149        server_send_time: u64,
150        client_receive_time: u64,
151    ) -> i64 {
152        let t1 = client_send_time as i64;
153        let t2 = server_receive_time as i64;
154        let t3 = server_send_time as i64;
155        let t4 = client_receive_time as i64;
156
157        ((t2 - t1) + (t3 - t4)) / 2
158    }
159}
160
161impl Default for ClockSync {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use std::{thread, time::Duration};
170
171    use super::*;
172
173    #[test]
174    fn test_synced_timestamp_default() {
175        let ts = SyncedTimestamp::default();
176        assert_eq!(ts.as_micros(), 0);
177    }
178
179    #[test]
180    fn test_synced_timestamp_from_micros() {
181        let ts = SyncedTimestamp::from_micros(1_000_000);
182        assert_eq!(ts.as_micros(), 1_000_000);
183    }
184
185    #[test]
186    fn test_synced_timestamp_ordering() {
187        let ts1 = SyncedTimestamp::from_micros(100);
188        let ts2 = SyncedTimestamp::from_micros(200);
189        assert!(ts1 < ts2);
190    }
191
192    #[test]
193    fn test_synced_timestamp_delta() {
194        let ts1 = SyncedTimestamp::from_micros(1000);
195        let ts2 = SyncedTimestamp::from_micros(500);
196        assert_eq!(ts1.delta(ts2), 500);
197        assert_eq!(ts2.delta(ts1), -500);
198    }
199
200    #[test]
201    fn test_to_sample_offset_in_buffer() {
202        let buffer_start = SyncedTimestamp::from_micros(0);
203        let event_time = SyncedTimestamp::from_micros(500);
204
205        let offset = event_time.to_sample_offset(buffer_start, 44100.0, 512).unwrap();
206
207        let expected = (0.0005 * 44100.0) as u32;
208        assert_eq!(offset, expected);
209    }
210
211    #[test]
212    fn test_to_sample_offset_past_event() {
213        let buffer_start = SyncedTimestamp::from_micros(1000);
214        let event_time = SyncedTimestamp::from_micros(500);
215
216        let offset = event_time.to_sample_offset(buffer_start, 44100.0, 512).unwrap();
217        assert_eq!(offset, 0);
218    }
219
220    #[test]
221    fn test_to_sample_offset_future_buffer() {
222        let buffer_start = SyncedTimestamp::from_micros(0);
223        let event_time = SyncedTimestamp::from_micros(1_000_000);
224
225        let offset = event_time.to_sample_offset(buffer_start, 44100.0, 512);
226        assert!(offset.is_none());
227    }
228
229    #[test]
230    fn test_clock_sync_now_increases() {
231        let clock = ClockSync::new();
232        let t1 = clock.now();
233        thread::sleep(Duration::from_millis(10));
234        let t2 = clock.now();
235        assert!(t2 > t1);
236    }
237
238    #[test]
239    fn test_clock_sync_tick_updates_cached() {
240        let clock = ClockSync::new();
241        clock.tick();
242        let cached1 = clock.cached_now();
243        thread::sleep(Duration::from_millis(10));
244        clock.tick();
245        let cached2 = clock.cached_now();
246        assert!(cached2 > cached1);
247    }
248
249    #[test]
250    fn test_calculate_offset_symmetric() {
251        let offset = ClockSync::calculate_offset(100, 200, 200, 300);
252        assert_eq!(offset, 0);
253    }
254
255    #[test]
256    fn test_calculate_offset_client_ahead() {
257        let offset = ClockSync::calculate_offset(100, 50, 50, 100);
258        assert!(offset < 0);
259    }
260
261    #[test]
262    fn test_calculate_offset_client_behind() {
263        let offset = ClockSync::calculate_offset(100, 250, 250, 300);
264        assert!(offset > 0);
265    }
266}