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}