Skip to main content

audio_clock_bsd/
clock.rs

1//! The [`ClockSource`] trait and the anchor-based sample↔timestamp conversion.
2//!
3//! The conversion between an audio **sample index** and a wall-clock **PTP
4//! timestamp** (nanoseconds) is purely a matter of a linear map anchored at a
5//! known reference point. [`ClockAnchor`] captures that map and performs the
6//! checked arithmetic; every concrete [`ClockSource`] holds an anchor and
7//! delegates the conversions to it.
8
9use crate::error::{ClockError, Result};
10
11/// Nanoseconds per second — the scaling factor between sample indices and
12/// timestamps at a given sample rate.
13const NS_PER_SEC: i64 = 1_000_000_000;
14
15/// A linear map between a sample index and a PTP timestamp.
16///
17/// Given a sample rate and a single anchor pair `(ref_sample, ref_ptp_ns)` —
18/// "sample index `ref_sample` occurred at PTP time `ref_ptp_ns`" — every other
19/// index or timestamp is determined by the linear relation:
20///
21/// ```text
22/// ptp_ns = ref_ptp_ns + (sample - ref_sample) * 1e9 / sample_rate
23/// sample = ref_sample + (ptp_ns - ref_ptp_ns) * sample_rate / 1e9
24/// ```
25///
26/// The arithmetic is performed in `i128` and checked against `i64` overflow,
27/// so a conversion that would wrap silently instead yields
28/// [`ClockError::ConversionOverflow`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ClockAnchor {
31    /// Sample rate in Hz (`48000` is the common baseline). Must be positive.
32    pub sample_rate: u32,
33    /// The sample index at the anchor point.
34    pub ref_sample: i64,
35    /// The PTP timestamp (nanoseconds since epoch) at the anchor point.
36    pub ref_ptp_ns: i64,
37}
38
39impl ClockAnchor {
40    /// Creates a new anchor, validating that the sample rate is positive.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`ClockError::InvalidSampleRate`] when `sample_rate == 0`.
45    pub fn new(sample_rate: u32, ref_sample: i64, ref_ptp_ns: i64) -> Result<Self> {
46        if sample_rate == 0 {
47            return Err(ClockError::InvalidSampleRate(0));
48        }
49        Ok(Self {
50            sample_rate,
51            ref_sample,
52            ref_ptp_ns,
53        })
54    }
55
56    /// Creates an anchor at sample index `0` with the given reference timestamp.
57    #[must_use]
58    pub fn at_origin(sample_rate: u32, ref_ptp_ns: i64) -> Option<Self> {
59        if sample_rate == 0 {
60            return None;
61        }
62        Some(Self {
63            sample_rate,
64            ref_sample: 0,
65            ref_ptp_ns,
66        })
67    }
68
69    /// Converts a sample index to a PTP timestamp in nanoseconds.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`ClockError::ConversionOverflow`] if the result does not fit in
74    /// `i64`.
75    pub fn sample_to_ptp_checked(&self, sample_idx: i64) -> Result<i64> {
76        // ptp_ns = ref_ptp_ns + (sample - ref_sample) * 1e9 / sample_rate
77        let delta_samples = i128::from(sample_idx) - i128::from(self.ref_sample);
78        let delta_ns = delta_samples
79            .checked_mul(i128::from(NS_PER_SEC))
80            .ok_or(ClockError::ConversionOverflow)?
81            / i128::from(self.sample_rate);
82        let ptp_ns = i128::from(self.ref_ptp_ns)
83            .checked_add(delta_ns)
84            .ok_or(ClockError::ConversionOverflow)?;
85        i64::try_from(ptp_ns).map_err(|_| ClockError::ConversionOverflow)
86    }
87
88    /// Converts a PTP timestamp in nanoseconds to a sample index.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ClockError::ConversionOverflow`] if the intermediate or result
93    /// does not fit in `i64`.
94    pub fn ptp_to_sample_checked(&self, ptp_ns: i64) -> Result<i64> {
95        // sample = ref_sample + (ptp_ns - ref_ptp_ns) * sample_rate / 1e9
96        let delta_ns = i128::from(ptp_ns) - i128::from(self.ref_ptp_ns);
97        let delta_samples = delta_ns
98            .checked_mul(i128::from(self.sample_rate))
99            .ok_or(ClockError::ConversionOverflow)?
100            / i128::from(NS_PER_SEC);
101        let sample = i128::from(self.ref_sample)
102            .checked_add(delta_samples)
103            .ok_or(ClockError::ConversionOverflow)?;
104        i64::try_from(sample).map_err(|_| ClockError::ConversionOverflow)
105    }
106}
107
108/// A source of PTP-aligned time that can map between sample indices and
109/// timestamps.
110///
111/// `ClockSource` is the single contract a host uses to timestamp audio samples.
112/// Concrete implementations (an `NtpClock` fallback or a `PtpClock`) own a
113/// [`ClockAnchor`] and a backing time reference.
114///
115/// # Threading
116///
117/// Implementations are `Send` but **not** real-time-safe in general:
118/// [`ClockSource::ptp_now_ns`] may block (polling a daemon, reading a device)
119/// and must run off the RT audio thread. The pure-conversion methods
120/// [`ClockSource::sample_to_ptp`] and [`ClockSource::ptp_to_sample`] are
121/// allocation-free and safe to call from the RT thread.
122///
123/// # Example
124///
125/// A minimal anchor-only clock (no live time source) implementing the trait:
126///
127/// ```
128/// use audio_clock_bsd::{ClockAnchor, ClockError, ClockSource, Result};
129///
130/// struct AnchorClock {
131///     anchor: ClockAnchor,
132/// }
133///
134/// impl ClockSource for AnchorClock {
135///     fn ptp_now_ns(&self) -> Result<i64> {
136///         // A real clock would read its backing time source here.
137///         Err(ClockError::Unavailable("no live source".into()))
138///     }
139///     fn sample_to_ptp(&self, sample_idx: i64) -> i64 {
140///         self.anchor.sample_to_ptp_checked(sample_idx).unwrap_or(i64::MIN)
141///     }
142///     fn ptp_to_sample(&self, ptp_ns: i64) -> i64 {
143///         self.anchor.ptp_to_sample_checked(ptp_ns).unwrap_or(i64::MIN)
144///     }
145/// }
146/// ```
147pub trait ClockSource: Send {
148    /// Returns the current PTP timestamp in nanoseconds since the epoch.
149    ///
150    /// This may block (daemon poll, device read) — call from a worker thread.
151    ///
152    /// # Errors
153    ///
154    /// - [`ClockError::Unavailable`] when no time source is present.
155    /// - [`ClockError::NotSynced`] when the source exists but has not locked.
156    fn ptp_now_ns(&self) -> Result<i64>;
157
158    /// Converts a sample index to a PTP timestamp in nanoseconds.
159    ///
160    /// Pure arithmetic over the clock's anchor — allocation-free, RT-safe.
161    /// Implementations MUST NOT panic; on internal overflow they return a
162    /// sentinel (`i64::MIN`) so the RT path is never aborted.
163    #[must_use]
164    fn sample_to_ptp(&self, sample_idx: i64) -> i64;
165
166    /// Converts a PTP timestamp in nanoseconds to a sample index.
167    ///
168    /// Pure arithmetic over the clock's anchor — allocation-free, RT-safe.
169    /// Implementations MUST NOT panic; on internal overflow they return a
170    /// sentinel (`i64::MIN`).
171    #[must_use]
172    fn ptp_to_sample(&self, ptp_ns: i64) -> i64;
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// Exact-enough integer equality helper.
180    fn approx(a: i64, b: i64) -> bool {
181        (a - b).abs() <= 1
182    }
183
184    #[test]
185    fn anchor_new_rejects_zero_sample_rate() {
186        let err = ClockAnchor::new(0, 0, 0).unwrap_err();
187        assert!(matches!(err, ClockError::InvalidSampleRate(0)));
188    }
189
190    #[test]
191    fn anchor_at_origin_none_for_zero_rate() {
192        assert!(ClockAnchor::at_origin(0, 1_000).is_none());
193        assert!(ClockAnchor::at_origin(48_000, 1_000).is_some());
194    }
195
196    #[test]
197    fn sample_to_ptp_at_anchor_returns_ref() {
198        let a = ClockAnchor::new(48_000, 1_000, 5_000_000_000).unwrap();
199        assert_eq!(a.sample_to_ptp_checked(1_000).unwrap(), 5_000_000_000);
200    }
201
202    #[test]
203    fn sample_to_ptp_one_second_advances_by_rate() {
204        // 48000 samples forward at 48000 Hz == exactly 1 s == 1e9 ns.
205        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
206        assert_eq!(a.sample_to_ptp_checked(48_000).unwrap(), NS_PER_SEC);
207    }
208
209    #[test]
210    fn sample_to_ptp_negative_index_goes_backward() {
211        let a = ClockAnchor::new(48_000, 48_000, NS_PER_SEC).unwrap();
212        // sample 0 is 1 second before the anchor.
213        assert_eq!(a.sample_to_ptp_checked(0).unwrap(), 0);
214    }
215
216    #[test]
217    fn ptp_to_sample_at_anchor_returns_ref() {
218        let a = ClockAnchor::new(48_000, 1_000, 5_000_000_000).unwrap();
219        assert_eq!(a.ptp_to_sample_checked(5_000_000_000).unwrap(), 1_000);
220    }
221
222    #[test]
223    fn ptp_to_sample_one_second_advances_by_rate() {
224        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
225        assert_eq!(a.ptp_to_sample_checked(NS_PER_SEC).unwrap(), 48_000);
226    }
227
228    #[test]
229    fn round_trip_sample_to_ptp_to_sample_is_identity() {
230        let a = ClockAnchor::new(48_000, 123_456, 9_000_000_000).unwrap();
231        for s in [0i64, 1, 123_456, 200_000, -50_000] {
232            let ptp = a.sample_to_ptp_checked(s).unwrap();
233            let back = a.ptp_to_sample_checked(ptp).unwrap();
234            assert!(approx(back, s), "round trip {s} -> {ptp} -> {back}");
235        }
236    }
237
238    #[test]
239    fn round_trip_ptp_to_sample_to_ptp_is_identity() {
240        let a = ClockAnchor::new(44_100, 0, 1_000_000_000).unwrap();
241        for p in [0i64, 1_000_000_000, 2_500_000_000, -3_000_000_000] {
242            let s = a.ptp_to_sample_checked(p).unwrap();
243            let back = a.sample_to_ptp_checked(s).unwrap();
244            assert!(approx(back, p), "round trip {p} -> {s} -> {back}");
245        }
246    }
247
248    #[test]
249    fn non_integer_rate_still_round_trips_within_tolerance() {
250        // 44100 Hz: 1 second = 44100 samples exactly, so integer identity holds.
251        let a = ClockAnchor::new(44_100, 0, 0).unwrap();
252        let ptp = a.sample_to_ptp_checked(44_100).unwrap();
253        assert_eq!(ptp, NS_PER_SEC);
254        let back = a.ptp_to_sample_checked(ptp).unwrap();
255        assert_eq!(back, 44_100);
256    }
257
258    #[test]
259    fn extreme_sample_does_not_panic() {
260        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
261        // Huge index — must either return a value or Err, never panic.
262        let _ = a.sample_to_ptp_checked(i64::MAX);
263        let _ = a.ptp_to_sample_checked(i64::MAX);
264    }
265}