use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
enum ClockState {
Stopped,
Running { started_at: Instant, base: Duration },
Paused { frozen_at: Duration },
}
pub struct PlaybackClock {
state: ClockState,
rate: f64,
seek_offset: Duration,
}
impl PlaybackClock {
#[must_use]
pub fn new() -> Self {
Self {
state: ClockState::Stopped,
rate: 1.0,
seek_offset: Duration::ZERO,
}
}
pub fn start(&mut self) {
let base = match &self.state {
ClockState::Running { .. } => return,
ClockState::Stopped => self.seek_offset,
ClockState::Paused { frozen_at } => *frozen_at,
};
self.state = ClockState::Running {
started_at: Instant::now(),
base,
};
}
pub fn stop(&mut self) {
self.state = ClockState::Stopped;
self.seek_offset = Duration::ZERO;
}
pub fn pause(&mut self) {
if let ClockState::Running { started_at, base } = &self.state {
let elapsed = started_at.elapsed().mul_f64(self.rate);
self.state = ClockState::Paused {
frozen_at: *base + elapsed,
};
}
}
pub fn resume(&mut self) {
if let ClockState::Paused { frozen_at } = self.state {
self.state = ClockState::Running {
started_at: Instant::now(),
base: frozen_at,
};
}
}
#[must_use]
pub fn current_time(&self) -> Duration {
match &self.state {
ClockState::Stopped => Duration::ZERO,
ClockState::Paused { frozen_at } => *frozen_at,
ClockState::Running { started_at, base } => {
*base + started_at.elapsed().mul_f64(self.rate)
}
}
}
#[must_use]
pub fn current_pts(&self) -> Duration {
match &self.state {
ClockState::Stopped => self.seek_offset,
_ => self.current_time(),
}
}
#[must_use]
pub fn is_running(&self) -> bool {
matches!(self.state, ClockState::Running { .. })
}
pub fn set_rate(&mut self, rate: f64) {
if rate <= 0.0 {
return;
}
if let ClockState::Running { started_at, base } = &mut self.state {
let elapsed = started_at.elapsed().mul_f64(self.rate);
*base += elapsed;
*started_at = Instant::now();
}
self.rate = rate;
}
#[must_use]
pub fn rate(&self) -> f64 {
self.rate
}
pub fn set_position(&mut self, pts: Duration) {
self.seek_offset = pts;
if matches!(self.state, ClockState::Running { .. }) {
self.state = ClockState::Running {
started_at: Instant::now(),
base: pts,
};
} else if matches!(self.state, ClockState::Paused { .. }) {
self.state = ClockState::Paused { frozen_at: pts };
}
}
}
impl Default for PlaybackClock {
fn default() -> Self {
Self::new()
}
}
pub(crate) enum MasterClock {
Audio {
samples_consumed: Arc<AtomicU64>,
sample_rate: u32,
fallback: Option<(Instant, Duration)>,
},
System {
started_at: Instant,
base_pts: Duration,
},
}
impl MasterClock {
#[allow(clippy::cast_precision_loss)]
pub(crate) fn current_pts(&self) -> Duration {
match self {
Self::Audio {
samples_consumed,
sample_rate,
fallback,
} => {
let s = samples_consumed.load(Ordering::Relaxed);
if s > 0 {
Duration::from_secs_f64(s as f64 / f64::from(*sample_rate))
} else if let Some((started_at, base_pts)) = fallback {
*base_pts + started_at.elapsed()
} else {
Duration::ZERO
}
}
Self::System {
started_at,
base_pts,
} => *base_pts + started_at.elapsed(),
}
}
pub(crate) fn should_sync(&self) -> bool {
match self {
Self::System { .. } => true,
Self::Audio {
samples_consumed,
fallback,
..
} => samples_consumed.load(Ordering::Relaxed) > 0 || fallback.is_some(),
}
}
pub(crate) fn activate_fallback_if_no_audio(&mut self, base_pts: Duration) {
if let Self::Audio {
samples_consumed,
fallback,
..
} = self
&& samples_consumed.load(Ordering::Relaxed) == 0
&& fallback.is_none()
{
*fallback = Some((Instant::now(), base_pts));
}
}
pub(crate) fn reset(&mut self, base: Duration) {
match self {
Self::System {
started_at,
base_pts,
} => {
*started_at = Instant::now();
*base_pts = base;
}
Self::Audio { fallback, .. } => {
if fallback.is_some() {
*fallback = Some((Instant::now(), base));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn clock_stopped_should_return_zero() {
let clock = PlaybackClock::new();
assert_eq!(clock.current_time(), Duration::ZERO);
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
clock.stop();
assert_eq!(
clock.current_time(),
Duration::ZERO,
"current_time() must be ZERO after stop()"
);
}
#[test]
fn clock_paused_should_freeze_at_pause_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
clock.pause();
let t1 = clock.current_time();
thread::sleep(Duration::from_millis(10));
let t2 = clock.current_time();
assert_eq!(t1, t2, "current_time() must not advance while paused");
assert!(
!clock.is_running(),
"clock must not report running while paused"
);
}
#[test]
fn clock_resumed_should_continue_from_pause() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
clock.pause();
let t_paused = clock.current_time();
thread::sleep(Duration::from_millis(10));
assert_eq!(clock.current_time(), t_paused);
clock.resume();
assert!(clock.is_running());
thread::sleep(Duration::from_millis(10));
let t_after = clock.current_time();
assert!(
t_after > t_paused,
"current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
);
}
#[test]
fn clock_start_should_be_noop_when_already_running() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
let t_before = clock.current_time();
clock.start();
let t_after = clock.current_time();
assert!(
t_after >= t_before,
"second start() must not reset the clock; before={t_before:?} after={t_after:?}"
);
}
#[test]
fn clock_resume_should_be_noop_when_not_paused() {
let mut clock = PlaybackClock::new();
clock.resume();
assert!(!clock.is_running());
assert_eq!(clock.current_time(), Duration::ZERO);
clock.start();
thread::sleep(Duration::from_millis(5));
let t = clock.current_time();
clock.resume(); assert!(clock.is_running());
assert!(clock.current_time() >= t);
}
#[test]
fn clock_default_should_equal_new() {
let a = PlaybackClock::new();
let b = PlaybackClock::default();
assert_eq!(a.current_time(), b.current_time());
assert_eq!(a.is_running(), b.is_running());
}
#[test]
fn set_rate_should_reject_non_positive_values() {
let mut clock = PlaybackClock::new();
clock.set_rate(0.0);
assert!(
(clock.rate() - 1.0).abs() < f64::EPSILON,
"rate must remain 1.0 after set_rate(0.0)"
);
clock.set_rate(-1.0);
assert!(
(clock.rate() - 1.0).abs() < f64::EPSILON,
"rate must remain 1.0 after set_rate(-1.0)"
);
}
#[test]
fn set_rate_should_update_rate_when_stopped_or_paused() {
let mut clock = PlaybackClock::new();
clock.set_rate(0.5);
assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
let mut clock = PlaybackClock::new();
clock.start();
clock.pause();
clock.set_rate(2.0);
assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
assert!(
!clock.is_running(),
"clock must remain paused after set_rate"
);
}
#[test]
fn set_rate_running_should_not_jump_current_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
let before = clock.current_time();
clock.set_rate(2.0);
let after = clock.current_time();
assert!(
after >= before,
"current_time() must not go backward on set_rate; before={before:?} after={after:?}"
);
assert!(
after - before < Duration::from_millis(20),
"current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
);
assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
}
#[test]
#[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
fn rate_two_x_should_advance_at_double_speed() {
let mut clock = PlaybackClock::new();
clock.set_rate(2.0);
clock.start();
thread::sleep(Duration::from_millis(50));
let elapsed = clock.current_time();
assert!(
elapsed >= Duration::from_millis(80),
"2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
);
}
#[test]
fn set_position_should_shift_pts_by_seek_offset() {
let seek_target = Duration::from_secs(30);
let mut clock = PlaybackClock::new();
clock.set_position(seek_target);
assert_eq!(
clock.current_pts(),
seek_target,
"current_pts() must reflect seek_offset when stopped"
);
clock.start();
let pts = clock.current_pts();
assert!(
pts >= seek_target,
"current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
);
assert!(
clock.is_running(),
"clock must be running after set_position + start()"
);
}
#[test]
fn set_position_while_paused_should_update_frozen_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
clock.pause();
let seek_target = Duration::from_secs(10);
clock.set_position(seek_target);
let pts = clock.current_pts();
assert_eq!(
pts, seek_target,
"frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
);
assert!(
!clock.is_running(),
"clock must remain paused after set_position"
);
clock.resume();
thread::sleep(Duration::from_millis(5));
let pts_after = clock.current_pts();
assert!(
pts_after > seek_target,
"current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
);
}
#[test]
fn set_position_while_running_should_continue_from_new_position() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
let seek_target = Duration::from_secs(60);
clock.set_position(seek_target);
let pts = clock.current_pts();
assert!(
pts >= seek_target,
"current_pts() must be ≥ seek target immediately after set_position while running; \
target={seek_target:?} pts={pts:?}"
);
assert!(
clock.is_running(),
"clock must remain running after set_position"
);
}
#[test]
fn stop_should_clear_seek_offset() {
let mut clock = PlaybackClock::new();
clock.set_position(Duration::from_secs(30));
clock.stop();
assert_eq!(
clock.current_pts(),
Duration::ZERO,
"stop() must reset seek_offset to ZERO"
);
}
#[test]
fn master_clock_system_should_advance_from_base_pts() {
let clock = MasterClock::System {
started_at: Instant::now(),
base_pts: Duration::from_secs(5),
};
let pts = clock.current_pts();
assert!(
pts >= Duration::from_secs(5),
"pts must be >= base_pts; got {pts:?}"
);
assert!(
pts < Duration::from_secs(6),
"pts must not advance 1 s in a unit test; got {pts:?}"
);
assert!(clock.should_sync(), "System clock must always sync");
}
#[test]
fn master_clock_system_reset_should_update_base_and_time_reference() {
let mut clock = MasterClock::System {
started_at: Instant::now() - Duration::from_secs(10),
base_pts: Duration::ZERO,
};
assert!(
clock.current_pts() >= Duration::from_secs(9),
"clock should show ~10 s before reset"
);
clock.reset(Duration::from_secs(5));
let pts = clock.current_pts();
assert!(
pts >= Duration::from_secs(5),
"pts must be >= new base after reset; got {pts:?}"
);
assert!(
pts < Duration::from_secs(6),
"pts must not advance 1 s in a unit test after reset; got {pts:?}"
);
}
#[test]
fn master_clock_audio_should_not_sync_before_first_sample() {
let clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
assert!(
!clock.should_sync(),
"audio clock must not sync before any samples are consumed and before fallback is armed"
);
assert_eq!(
clock.current_pts(),
Duration::ZERO,
"audio clock PTS must be zero before any samples and before fallback is armed"
);
}
#[test]
fn master_clock_audio_should_sync_and_report_pts_after_samples_consumed() {
let consumed = Arc::new(AtomicU64::new(48_000));
let clock = MasterClock::Audio {
samples_consumed: Arc::clone(&consumed),
sample_rate: 48_000,
fallback: None,
};
assert!(
clock.should_sync(),
"audio clock must sync when samples > 0"
);
assert_eq!(
clock.current_pts(),
Duration::from_secs(1),
"48000 samples at 48000 Hz must equal 1 second"
);
}
#[test]
fn master_clock_audio_should_sync_after_fallback_activated() {
let mut clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
assert!(
!clock.should_sync(),
"must not sync before fallback is armed"
);
clock.activate_fallback_if_no_audio(Duration::from_secs(1));
assert!(
clock.should_sync(),
"must sync after fallback is activated even when samples_consumed == 0"
);
}
#[test]
fn master_clock_audio_fallback_current_pts_should_advance_from_base_pts() {
let mut clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
let base = Duration::from_secs(5);
clock.activate_fallback_if_no_audio(base);
let pts = clock.current_pts();
assert!(
pts >= base,
"fallback current_pts must be >= base_pts; got {pts:?}"
);
assert!(
pts < base + Duration::from_secs(1),
"fallback must not advance 1 s in a unit test; got {pts:?}"
);
}
#[test]
fn master_clock_audio_should_prefer_samples_over_fallback_when_consumer_starts() {
let consumed = Arc::new(AtomicU64::new(0));
let mut clock = MasterClock::Audio {
samples_consumed: Arc::clone(&consumed),
sample_rate: 48_000,
fallback: None,
};
clock.activate_fallback_if_no_audio(Duration::from_secs(2));
assert!(clock.should_sync(), "fallback must enable sync");
consumed.store(48_000, Ordering::Relaxed);
assert_eq!(
clock.current_pts(),
Duration::from_secs(1),
"48000 samples at 48 kHz must report 1 s even when fallback is also armed"
);
}
#[test]
fn master_clock_audio_activate_fallback_should_be_idempotent() {
let mut clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
clock.activate_fallback_if_no_audio(Duration::from_secs(1));
let pts1 = clock.current_pts();
thread::sleep(Duration::from_millis(5));
clock.activate_fallback_if_no_audio(Duration::from_secs(100));
let pts2 = clock.current_pts();
assert!(
pts2 > pts1,
"clock must keep advancing from the first base after second activate; \
pts1={pts1:?} pts2={pts2:?}"
);
assert!(
pts2 < Duration::from_secs(5),
"second activate must not reset clock to base=100 s; pts2={pts2:?}"
);
}
#[test]
fn master_clock_audio_reset_should_update_fallback_base_pts() {
let mut clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
clock.activate_fallback_if_no_audio(Duration::from_secs(5));
clock.reset(Duration::from_secs(10));
let pts = clock.current_pts();
assert!(
pts >= Duration::from_secs(10),
"after reset, fallback must advance from the new base_pts; got {pts:?}"
);
assert!(
pts < Duration::from_secs(11),
"fallback must not advance 1 s in a unit test after reset; got {pts:?}"
);
}
#[test]
fn master_clock_audio_reset_should_not_arm_fallback_if_not_yet_active() {
let mut clock = MasterClock::Audio {
samples_consumed: Arc::new(AtomicU64::new(0)),
sample_rate: 48_000,
fallback: None,
};
clock.reset(Duration::ZERO);
assert!(
!clock.should_sync(),
"reset() before activate_fallback_if_no_audio must not arm the fallback"
);
assert_eq!(
clock.current_pts(),
Duration::ZERO,
"PTS must remain ZERO when fallback is not yet armed"
);
}
#[test]
fn master_clock_system_activate_fallback_should_be_noop() {
let mut clock = MasterClock::System {
started_at: Instant::now(),
base_pts: Duration::ZERO,
};
clock.activate_fallback_if_no_audio(Duration::from_secs(99));
assert!(
clock.should_sync(),
"System clock must always sync regardless of activate_fallback_if_no_audio"
);
}
}