audio-clock-bsd 0.1.0

PTP/NTP clock synchronization and sample-index to timestamp conversion for real-time audio
Documentation
//! The [`ClockSource`] trait and the anchor-based sample↔timestamp conversion.
//!
//! The conversion between an audio **sample index** and a wall-clock **PTP
//! timestamp** (nanoseconds) is purely a matter of a linear map anchored at a
//! known reference point. [`ClockAnchor`] captures that map and performs the
//! checked arithmetic; every concrete [`ClockSource`] holds an anchor and
//! delegates the conversions to it.

use crate::error::{ClockError, Result};

/// Nanoseconds per second — the scaling factor between sample indices and
/// timestamps at a given sample rate.
const NS_PER_SEC: i64 = 1_000_000_000;

/// A linear map between a sample index and a PTP timestamp.
///
/// Given a sample rate and a single anchor pair `(ref_sample, ref_ptp_ns)` —
/// "sample index `ref_sample` occurred at PTP time `ref_ptp_ns`" — every other
/// index or timestamp is determined by the linear relation:
///
/// ```text
/// ptp_ns = ref_ptp_ns + (sample - ref_sample) * 1e9 / sample_rate
/// sample = ref_sample + (ptp_ns - ref_ptp_ns) * sample_rate / 1e9
/// ```
///
/// The arithmetic is performed in `i128` and checked against `i64` overflow,
/// so a conversion that would wrap silently instead yields
/// [`ClockError::ConversionOverflow`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClockAnchor {
    /// Sample rate in Hz (`48000` is the common baseline). Must be positive.
    pub sample_rate: u32,
    /// The sample index at the anchor point.
    pub ref_sample: i64,
    /// The PTP timestamp (nanoseconds since epoch) at the anchor point.
    pub ref_ptp_ns: i64,
}

impl ClockAnchor {
    /// Creates a new anchor, validating that the sample rate is positive.
    ///
    /// # Errors
    ///
    /// Returns [`ClockError::InvalidSampleRate`] when `sample_rate == 0`.
    pub fn new(sample_rate: u32, ref_sample: i64, ref_ptp_ns: i64) -> Result<Self> {
        if sample_rate == 0 {
            return Err(ClockError::InvalidSampleRate(0));
        }
        Ok(Self {
            sample_rate,
            ref_sample,
            ref_ptp_ns,
        })
    }

    /// Creates an anchor at sample index `0` with the given reference timestamp.
    #[must_use]
    pub fn at_origin(sample_rate: u32, ref_ptp_ns: i64) -> Option<Self> {
        if sample_rate == 0 {
            return None;
        }
        Some(Self {
            sample_rate,
            ref_sample: 0,
            ref_ptp_ns,
        })
    }

    /// Converts a sample index to a PTP timestamp in nanoseconds.
    ///
    /// # Errors
    ///
    /// Returns [`ClockError::ConversionOverflow`] if the result does not fit in
    /// `i64`.
    pub fn sample_to_ptp_checked(&self, sample_idx: i64) -> Result<i64> {
        // ptp_ns = ref_ptp_ns + (sample - ref_sample) * 1e9 / sample_rate
        let delta_samples = i128::from(sample_idx) - i128::from(self.ref_sample);
        let delta_ns = delta_samples
            .checked_mul(i128::from(NS_PER_SEC))
            .ok_or(ClockError::ConversionOverflow)?
            / i128::from(self.sample_rate);
        let ptp_ns = i128::from(self.ref_ptp_ns)
            .checked_add(delta_ns)
            .ok_or(ClockError::ConversionOverflow)?;
        i64::try_from(ptp_ns).map_err(|_| ClockError::ConversionOverflow)
    }

    /// Converts a PTP timestamp in nanoseconds to a sample index.
    ///
    /// # Errors
    ///
    /// Returns [`ClockError::ConversionOverflow`] if the intermediate or result
    /// does not fit in `i64`.
    pub fn ptp_to_sample_checked(&self, ptp_ns: i64) -> Result<i64> {
        // sample = ref_sample + (ptp_ns - ref_ptp_ns) * sample_rate / 1e9
        let delta_ns = i128::from(ptp_ns) - i128::from(self.ref_ptp_ns);
        let delta_samples = delta_ns
            .checked_mul(i128::from(self.sample_rate))
            .ok_or(ClockError::ConversionOverflow)?
            / i128::from(NS_PER_SEC);
        let sample = i128::from(self.ref_sample)
            .checked_add(delta_samples)
            .ok_or(ClockError::ConversionOverflow)?;
        i64::try_from(sample).map_err(|_| ClockError::ConversionOverflow)
    }
}

/// A source of PTP-aligned time that can map between sample indices and
/// timestamps.
///
/// `ClockSource` is the single contract a host uses to timestamp audio samples.
/// Concrete implementations (an `NtpClock` fallback or a `PtpClock`) own a
/// [`ClockAnchor`] and a backing time reference.
///
/// # Threading
///
/// Implementations are `Send` but **not** real-time-safe in general:
/// [`ClockSource::ptp_now_ns`] may block (polling a daemon, reading a device)
/// and must run off the RT audio thread. The pure-conversion methods
/// [`ClockSource::sample_to_ptp`] and [`ClockSource::ptp_to_sample`] are
/// allocation-free and safe to call from the RT thread.
///
/// # Example
///
/// A minimal anchor-only clock (no live time source) implementing the trait:
///
/// ```
/// use audio_clock_bsd::{ClockAnchor, ClockError, ClockSource, Result};
///
/// struct AnchorClock {
///     anchor: ClockAnchor,
/// }
///
/// impl ClockSource for AnchorClock {
///     fn ptp_now_ns(&self) -> Result<i64> {
///         // A real clock would read its backing time source here.
///         Err(ClockError::Unavailable("no live source".into()))
///     }
///     fn sample_to_ptp(&self, sample_idx: i64) -> i64 {
///         self.anchor.sample_to_ptp_checked(sample_idx).unwrap_or(i64::MIN)
///     }
///     fn ptp_to_sample(&self, ptp_ns: i64) -> i64 {
///         self.anchor.ptp_to_sample_checked(ptp_ns).unwrap_or(i64::MIN)
///     }
/// }
/// ```
pub trait ClockSource: Send {
    /// Returns the current PTP timestamp in nanoseconds since the epoch.
    ///
    /// This may block (daemon poll, device read) — call from a worker thread.
    ///
    /// # Errors
    ///
    /// - [`ClockError::Unavailable`] when no time source is present.
    /// - [`ClockError::NotSynced`] when the source exists but has not locked.
    fn ptp_now_ns(&self) -> Result<i64>;

    /// Converts a sample index to a PTP timestamp in nanoseconds.
    ///
    /// Pure arithmetic over the clock's anchor — allocation-free, RT-safe.
    /// Implementations MUST NOT panic; on internal overflow they return a
    /// sentinel (`i64::MIN`) so the RT path is never aborted.
    #[must_use]
    fn sample_to_ptp(&self, sample_idx: i64) -> i64;

    /// Converts a PTP timestamp in nanoseconds to a sample index.
    ///
    /// Pure arithmetic over the clock's anchor — allocation-free, RT-safe.
    /// Implementations MUST NOT panic; on internal overflow they return a
    /// sentinel (`i64::MIN`).
    #[must_use]
    fn ptp_to_sample(&self, ptp_ns: i64) -> i64;
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Exact-enough integer equality helper.
    fn approx(a: i64, b: i64) -> bool {
        (a - b).abs() <= 1
    }

    #[test]
    fn anchor_new_rejects_zero_sample_rate() {
        let err = ClockAnchor::new(0, 0, 0).unwrap_err();
        assert!(matches!(err, ClockError::InvalidSampleRate(0)));
    }

    #[test]
    fn anchor_at_origin_none_for_zero_rate() {
        assert!(ClockAnchor::at_origin(0, 1_000).is_none());
        assert!(ClockAnchor::at_origin(48_000, 1_000).is_some());
    }

    #[test]
    fn sample_to_ptp_at_anchor_returns_ref() {
        let a = ClockAnchor::new(48_000, 1_000, 5_000_000_000).unwrap();
        assert_eq!(a.sample_to_ptp_checked(1_000).unwrap(), 5_000_000_000);
    }

    #[test]
    fn sample_to_ptp_one_second_advances_by_rate() {
        // 48000 samples forward at 48000 Hz == exactly 1 s == 1e9 ns.
        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
        assert_eq!(a.sample_to_ptp_checked(48_000).unwrap(), NS_PER_SEC);
    }

    #[test]
    fn sample_to_ptp_negative_index_goes_backward() {
        let a = ClockAnchor::new(48_000, 48_000, NS_PER_SEC).unwrap();
        // sample 0 is 1 second before the anchor.
        assert_eq!(a.sample_to_ptp_checked(0).unwrap(), 0);
    }

    #[test]
    fn ptp_to_sample_at_anchor_returns_ref() {
        let a = ClockAnchor::new(48_000, 1_000, 5_000_000_000).unwrap();
        assert_eq!(a.ptp_to_sample_checked(5_000_000_000).unwrap(), 1_000);
    }

    #[test]
    fn ptp_to_sample_one_second_advances_by_rate() {
        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
        assert_eq!(a.ptp_to_sample_checked(NS_PER_SEC).unwrap(), 48_000);
    }

    #[test]
    fn round_trip_sample_to_ptp_to_sample_is_identity() {
        let a = ClockAnchor::new(48_000, 123_456, 9_000_000_000).unwrap();
        for s in [0i64, 1, 123_456, 200_000, -50_000] {
            let ptp = a.sample_to_ptp_checked(s).unwrap();
            let back = a.ptp_to_sample_checked(ptp).unwrap();
            assert!(approx(back, s), "round trip {s} -> {ptp} -> {back}");
        }
    }

    #[test]
    fn round_trip_ptp_to_sample_to_ptp_is_identity() {
        let a = ClockAnchor::new(44_100, 0, 1_000_000_000).unwrap();
        for p in [0i64, 1_000_000_000, 2_500_000_000, -3_000_000_000] {
            let s = a.ptp_to_sample_checked(p).unwrap();
            let back = a.sample_to_ptp_checked(s).unwrap();
            assert!(approx(back, p), "round trip {p} -> {s} -> {back}");
        }
    }

    #[test]
    fn non_integer_rate_still_round_trips_within_tolerance() {
        // 44100 Hz: 1 second = 44100 samples exactly, so integer identity holds.
        let a = ClockAnchor::new(44_100, 0, 0).unwrap();
        let ptp = a.sample_to_ptp_checked(44_100).unwrap();
        assert_eq!(ptp, NS_PER_SEC);
        let back = a.ptp_to_sample_checked(ptp).unwrap();
        assert_eq!(back, 44_100);
    }

    #[test]
    fn extreme_sample_does_not_panic() {
        let a = ClockAnchor::new(48_000, 0, 0).unwrap();
        // Huge index — must either return a value or Err, never panic.
        let _ = a.sample_to_ptp_checked(i64::MAX);
        let _ = a.ptp_to_sample_checked(i64::MAX);
    }
}