audio-clock-bsd 0.1.1

PTP/NTP clock synchronization and sample-index to timestamp conversion for real-time audio
Documentation
//! [`NtpClock`] — the default system-clock fallback for [`ClockSource`].
//!
//! [`NtpClock`] anchors the sample-index↔timestamp map to the host wall clock
//! via [`std::time::SystemTime`]. It needs no daemon and no hardware: it is the
//! always-available fallback used when no PTP hardware clock is present.
//!
//! # Epoch note
//!
//! Timestamps are reported as **nanoseconds since the UNIX epoch**
//! (1970-01-01T00:00:00Z), obtained from [`SystemTime::now`]. This is a
//! pragmatic "PTP-like" time for the fallback path. A real PTP clock reports
//! TAI nanoseconds since the PTP epoch; the ~37 s leap-second offset between
//! TAI and UTC, and the 1970-vs-1900 epoch difference, are a deployment
//! concern handled by the PTP backend — `NtpClock` deliberately does not model
//! them.

use std::time::{SystemTime, UNIX_EPOCH};

use crate::clock::{ClockAnchor, ClockSource};
use crate::error::{ClockError, Result};

/// Reads the current wall-clock time as nanoseconds since the UNIX epoch.
///
/// Returns [`ClockError::NotSynced`] if the system clock reports a time before
/// the epoch, and [`ClockError::ConversionOverflow`] if the nanosecond count
/// does not fit in `i64` (only possible after roughly the year 2262).
pub(crate) fn system_now_ns() -> Result<i64> {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|e| ClockError::NotSynced(format!("system clock before epoch: {e}")))?;
    i64::try_from(duration.as_nanos()).map_err(|_| ClockError::ConversionOverflow)
}

/// A [`ClockSource`] anchored to the host wall clock (NTP fallback).
///
/// On construction, sample index `0` is anchored to the current wall-clock
/// time. The anchor may be moved later with [`NtpClock::reanchor`] (e.g. when
/// an audio device reports its actual start time) and inspected with
/// [`NtpClock::anchor`].
pub struct NtpClock {
    anchor: ClockAnchor,
}

impl std::fmt::Debug for NtpClock {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NtpClock")
            .field("anchor", &self.anchor)
            .finish()
    }
}

impl NtpClock {
    /// Creates a clock anchored at sample `0` to the current wall-clock time.
    ///
    /// # Errors
    ///
    /// - [`ClockError::InvalidSampleRate`] when `sample_rate == 0`.
    /// - [`ClockError::NotSynced`] when the system clock is before the epoch.
    pub fn new(sample_rate: u32) -> Result<Self> {
        let now_ns = system_now_ns()?;
        Ok(Self {
            anchor: ClockAnchor::new(sample_rate, 0, now_ns)?,
        })
    }

    /// Creates a clock over a pre-built anchor (e.g. restored from a session).
    #[must_use]
    pub fn with_anchor(anchor: ClockAnchor) -> Self {
        Self { anchor }
    }

    /// Returns the current anchor.
    #[must_use]
    pub fn anchor(&self) -> ClockAnchor {
        self.anchor
    }

    /// Re-anchors so that `ref_sample` maps to the *current* wall-clock time.
    ///
    /// Useful when an audio device reports the actual start sample after open.
    ///
    /// # Errors
    ///
    /// - [`ClockError::NotSynced`] when the system clock is before the epoch.
    pub fn reanchor(&mut self, ref_sample: i64) -> Result<()> {
        let now_ns = system_now_ns()?;
        self.anchor = ClockAnchor::new(self.anchor.sample_rate, ref_sample, now_ns)?;
        Ok(())
    }
}

impl ClockSource for NtpClock {
    fn ptp_now_ns(&self) -> Result<i64> {
        system_now_ns()
    }

    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)
    }
}

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

    #[test]
    fn new_anchors_sample_zero_to_now() {
        let before = system_now_ns().unwrap();
        let clock = NtpClock::new(48_000).unwrap();
        let after = system_now_ns().unwrap();
        // Sample 0 must map to within the construction window.
        let t0 = clock.sample_to_ptp(0);
        assert!(
            t0 >= before && t0 <= after,
            "t0={t0} not in [{before},{after}]"
        );
    }

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

    #[test]
    fn ptp_now_ns_is_monotonic_within_window() {
        let clock = NtpClock::new(48_000).unwrap();
        let a = clock.ptp_now_ns().unwrap();
        let b = clock.ptp_now_ns().unwrap();
        assert!(b >= a, "wall clock went backwards: {a} -> {b}");
    }

    #[test]
    fn sample_to_ptp_advances_with_rate() {
        let clock = NtpClock::with_anchor(ClockAnchor::new(48_000, 0, 0).unwrap());
        // 48000 samples == 1 s == 1e9 ns.
        assert_eq!(clock.sample_to_ptp(48_000), 1_000_000_000);
    }

    #[test]
    fn ptp_to_sample_inverse_of_anchor() {
        let clock = NtpClock::with_anchor(ClockAnchor::new(48_000, 100, 2_000_000_000).unwrap());
        assert_eq!(clock.ptp_to_sample(2_000_000_000), 100);
    }

    #[test]
    fn reanchor_moves_the_reference_sample() {
        let mut clock = NtpClock::with_anchor(ClockAnchor::new(48_000, 0, 0).unwrap());
        let before = system_now_ns().unwrap();
        clock.reanchor(1_000).unwrap();
        // After reanchor, sample 1000 maps to the reanchor moment.
        let t = clock.sample_to_ptp(1_000);
        let after = system_now_ns().unwrap();
        assert!(t >= before && t <= after, "t={t} not in [{before},{after}]");
    }

    #[test]
    fn dyn_clock_source_object_compiles() {
        let clock: Box<dyn ClockSource> = Box::new(NtpClock::new(48_000).unwrap());
        assert!(clock.ptp_now_ns().is_ok());
    }
}