use super::TickSchedule;
use crate::TransportEvent;
#[derive(Debug)]
pub(crate) struct TempoEstimator {
timestamps: Vec<u64>,
head: usize,
count: usize,
window_size: usize,
filtered_interval_ns: Option<f64>,
alpha: f64,
last_tick_ns: Option<u64>,
}
impl TempoEstimator {
pub(crate) fn new(window_size: usize) -> Self {
let window_size = window_size.max(2);
Self {
timestamps: vec![0; window_size],
head: 0,
count: 0,
window_size,
filtered_interval_ns: None,
alpha: 2.0 / (window_size as f64 + 1.0),
last_tick_ns: None,
}
}
pub(crate) fn feed_tick(&mut self, timestamp_ns: u64) {
let prev_tick = self.last_tick_ns;
self.last_tick_ns = Some(timestamp_ns);
self.timestamps[self.head] = timestamp_ns;
self.head = (self.head + 1) % self.window_size;
self.count += 1;
let Some(prev) = prev_tick else {
return;
};
let instantaneous = timestamp_ns.saturating_sub(prev) as f64;
if let Some(filtered) = self.filtered_interval_ns
&& (instantaneous > 3.0 * filtered || instantaneous < filtered / 3.0)
{
self.filtered_interval_ns = Some(instantaneous);
self.count = 2; return;
}
match self.filtered_interval_ns {
Some(filtered) => {
self.filtered_interval_ns =
Some(self.alpha * instantaneous + (1.0 - self.alpha) * filtered);
}
None => {
self.filtered_interval_ns = Some(instantaneous);
}
}
}
pub(crate) fn estimated_interval_ns(&self) -> Option<u64> {
if self.count < 4 {
return None;
}
self.filtered_interval_ns.map(|f| f as u64)
}
pub(crate) fn estimated_bpm(&self) -> Option<f64> {
let interval_ns = self.estimated_interval_ns()?;
if interval_ns == 0 {
return None;
}
Some(60.0 / (interval_ns as f64 * 24.0 / 1_000_000_000.0))
}
pub(crate) fn is_locked(&self) -> bool {
self.count >= 4 && self.filtered_interval_ns.is_some()
}
pub(crate) fn check_dropout(&mut self, now_ns: u64) -> bool {
let (Some(last), Some(interval_ns)) = (self.last_tick_ns, self.filtered_interval_ns) else {
return false;
};
let elapsed = now_ns.saturating_sub(last) as f64;
if elapsed > 4.0 * interval_ns {
self.reset();
return true;
}
false
}
fn reset(&mut self) {
self.count = 0;
self.head = 0;
self.filtered_interval_ns = None;
self.last_tick_ns = None;
}
fn raw_filtered_interval_ns(&self) -> Option<f64> {
self.filtered_interval_ns
}
pub(crate) fn last_tick_ns(&self) -> Option<u64> {
self.last_tick_ns
}
}
#[derive(Debug)]
pub(crate) struct SlaveOscillator {
next_tick_ns: Option<u64>,
subdivision: u8,
beat: u64,
phase_gain: f64,
}
impl SlaveOscillator {
pub(crate) fn new() -> Self {
Self {
next_tick_ns: None,
subdivision: 0,
beat: 0,
phase_gain: 0.2,
}
}
pub(crate) fn sync_to_external(&mut self, external_tick_ns: u64, estimated_interval_ns: u64) {
match self.next_tick_ns {
None => {
self.next_tick_ns = Some(external_tick_ns + estimated_interval_ns);
}
Some(expected) => {
let phase_error = external_tick_ns as f64 - expected as f64;
let correction = (self.phase_gain * phase_error) as i64;
if correction >= 0 {
self.next_tick_ns = Some(expected + correction as u64);
} else {
self.next_tick_ns = Some(expected.saturating_sub(correction.unsigned_abs()));
}
}
}
}
pub(crate) fn next_tick(&self, estimated_interval_ns: u64) -> Option<TickSchedule> {
self.next_tick_ns.map(|next| TickSchedule {
next_tick_ns: next,
interval_ns: estimated_interval_ns,
subdivision: self.subdivision,
beat: self.beat,
})
}
pub(crate) fn advance(&mut self, estimated_interval_ns: u64) {
if let Some(ref mut next) = self.next_tick_ns {
*next += estimated_interval_ns;
}
self.subdivision += 1;
if self.subdivision >= 24 {
self.subdivision = 0;
self.beat += 1;
}
}
pub(crate) fn reset_position(&mut self) {
self.beat = 0;
self.subdivision = 0;
}
pub(crate) fn stop(&mut self) {
self.next_tick_ns = None;
}
pub(crate) fn resume(&mut self, now_ns: u64, estimated_interval_ns: u64) {
if self.next_tick_ns.is_none() {
self.next_tick_ns = Some(now_ns + estimated_interval_ns);
}
}
pub(crate) fn set_position_from_spp(&mut self, midi_beats: u16) {
self.beat = (midi_beats / 4) as u64;
self.subdivision = ((midi_beats % 4) * 6) as u8;
}
}
#[derive(Debug)]
pub(crate) struct SlaveClock {
estimator: TempoEstimator,
oscillator: SlaveOscillator,
timeout_ns: u64,
is_running: bool,
}
impl SlaveClock {
pub(crate) fn new(timeout_ns: u64) -> Self {
Self {
estimator: TempoEstimator::new(8),
oscillator: SlaveOscillator::new(),
timeout_ns,
is_running: true,
}
}
pub(crate) fn feed_clock_byte(&mut self, timestamp_ns: u64) {
self.estimator.feed_tick(timestamp_ns);
if let Some(interval) = self.estimator.estimated_interval_ns() {
self.oscillator.sync_to_external(timestamp_ns, interval);
} else if let Some(raw_interval) = self.estimator.raw_filtered_interval_ns() {
let interval = raw_interval as u64;
if interval > 0 {
self.oscillator.sync_to_external(timestamp_ns, interval);
}
}
}
pub(crate) fn feed_transport(&mut self, event: TransportEvent, now_ns: u64) {
match event {
TransportEvent::Start => {
self.oscillator.reset_position();
self.is_running = true;
}
TransportEvent::Stop => {
self.oscillator.stop();
self.is_running = false;
}
TransportEvent::Continue => {
self.is_running = true;
if let Some(interval) = self.estimator.estimated_interval_ns() {
self.oscillator.resume(now_ns, interval);
} else if let Some(raw) = self.estimator.raw_filtered_interval_ns() {
let interval = raw as u64;
if interval > 0 {
self.oscillator.resume(now_ns, interval);
}
}
}
}
}
pub(crate) fn feed_spp(&mut self, position: u16) {
self.oscillator.set_position_from_spp(position);
}
pub(crate) fn next_tick(&self) -> Option<TickSchedule> {
if !self.is_running || !self.estimator.is_locked() {
return None;
}
let interval = self.estimator.estimated_interval_ns()?;
self.oscillator.next_tick(interval)
}
pub(crate) fn advance(&mut self) {
if let Some(interval) = self.estimator.estimated_interval_ns() {
self.oscillator.advance(interval);
}
}
#[cfg(test)]
pub(crate) fn is_locked(&self) -> bool {
self.estimator.is_locked()
}
pub(crate) fn estimated_bpm(&self) -> Option<f64> {
self.estimator.estimated_bpm()
}
pub(crate) fn check_dropout(&mut self, now_ns: u64) -> bool {
if self.estimator.check_dropout(now_ns) {
self.oscillator.stop();
return true;
}
if let Some(last_tick) = self.estimator.last_tick_ns()
&& now_ns.saturating_sub(last_tick) > self.timeout_ns
{
self.oscillator.stop();
return true;
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
const INTERVAL_120_BPM_NS: u64 = 20_833_333;
const INTERVAL_140_BPM_NS: u64 = 17_857_142;
#[test]
fn test_estimator_not_locked_initially() {
let estimator = TempoEstimator::new(8);
assert!(!estimator.is_locked());
assert_eq!(estimator.estimated_interval_ns(), None);
assert_eq!(estimator.estimated_bpm(), None);
}
#[test]
fn test_estimator_locks_after_4_ticks() {
let mut estimator = TempoEstimator::new(8);
for i in 0..4 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(estimator.is_locked());
let bpm = estimator.estimated_bpm().expect("should have BPM estimate");
let error_pct = ((bpm - 120.0) / 120.0).abs() * 100.0;
assert!(
error_pct < 1.0,
"expected BPM within 1% of 120, got {bpm} (error: {error_pct:.2}%)"
);
}
#[test]
fn test_estimator_smooths_jitter() {
let mut estimator = TempoEstimator::new(8);
let jitter_ns: i64 = 500_000; for i in 0..48 {
let ideal_time = i as u64 * INTERVAL_120_BPM_NS;
let jitter = if i % 2 == 0 { jitter_ns } else { -jitter_ns };
let timestamp = (ideal_time as i64 + jitter) as u64;
estimator.feed_tick(timestamp);
}
assert!(estimator.is_locked());
let bpm = estimator.estimated_bpm().expect("should have BPM estimate");
let error_pct = ((bpm - 120.0) / 120.0).abs() * 100.0;
assert!(
error_pct < 1.0,
"expected BPM within 1% of 120, got {bpm} (error: {error_pct:.2}%)"
);
}
#[test]
fn test_estimator_follows_tempo_change() {
let mut estimator = TempoEstimator::new(8);
for i in 0..24 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
let base_time = 23 * INTERVAL_120_BPM_NS;
for i in 1..=24 {
estimator.feed_tick(base_time + i * INTERVAL_140_BPM_NS);
}
let bpm = estimator.estimated_bpm().expect("should have BPM estimate");
let error_pct = ((bpm - 140.0) / 140.0).abs() * 100.0;
assert!(
error_pct < 2.0,
"expected BPM within 2% of 140, got {bpm} (error: {error_pct:.2}%)"
);
}
#[test]
fn test_estimator_detects_dropout() {
let mut estimator = TempoEstimator::new(8);
for i in 0..10 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(estimator.is_locked());
let last_tick = 9 * INTERVAL_120_BPM_NS;
let dropout_time = last_tick + 200_000_000; assert!(estimator.check_dropout(dropout_time));
assert!(!estimator.is_locked());
}
#[test]
fn test_estimator_handles_tempo_jump() {
let mut estimator = TempoEstimator::new(8);
for i in 0..12 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(estimator.is_locked());
let bpm_before = estimator.estimated_bpm().expect("should be locked");
assert!(
(bpm_before - 120.0).abs() < 2.0,
"should be near 120 BPM, got {bpm_before}"
);
let base_time = 11 * INTERVAL_120_BPM_NS;
let interval_30_bpm = INTERVAL_120_BPM_NS * 4;
for i in 1..=12 {
estimator.feed_tick(base_time + i as u64 * interval_30_bpm);
}
assert!(
estimator.is_locked(),
"should re-lock after tempo jump within ~8 ticks"
);
let bpm = estimator.estimated_bpm().expect("should have BPM estimate");
let error_pct = ((bpm - 30.0) / 30.0).abs() * 100.0;
assert!(
error_pct < 5.0,
"expected BPM within 5% of 30, got {bpm} (error: {error_pct:.2}%)"
);
}
#[test]
fn test_oscillator_not_running_initially() {
let osc = SlaveOscillator::new();
assert_eq!(osc.next_tick(INTERVAL_120_BPM_NS), None);
}
#[test]
fn test_oscillator_starts_on_first_sync() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(1000, INTERVAL_120_BPM_NS);
let schedule = osc.next_tick(INTERVAL_120_BPM_NS);
assert!(
schedule.is_some(),
"oscillator should produce a tick after sync"
);
let s = schedule.expect("already checked");
assert_eq!(s.next_tick_ns, 1000 + INTERVAL_120_BPM_NS);
assert_eq!(s.subdivision, 0);
assert_eq!(s.beat, 0);
}
#[test]
fn test_oscillator_subdivision_wraps() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(0, INTERVAL_120_BPM_NS);
for _ in 0..24 {
osc.advance(INTERVAL_120_BPM_NS);
}
let schedule = osc
.next_tick(INTERVAL_120_BPM_NS)
.expect("should be running");
assert_eq!(schedule.subdivision, 0);
assert_eq!(schedule.beat, 1);
}
#[test]
fn test_oscillator_stop_and_resume() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(0, INTERVAL_120_BPM_NS);
assert!(osc.next_tick(INTERVAL_120_BPM_NS).is_some());
osc.stop();
assert_eq!(osc.next_tick(INTERVAL_120_BPM_NS), None);
osc.resume(100_000_000, INTERVAL_120_BPM_NS);
let schedule = osc
.next_tick(INTERVAL_120_BPM_NS)
.expect("should be running after resume");
assert_eq!(schedule.next_tick_ns, 100_000_000 + INTERVAL_120_BPM_NS);
}
#[test]
fn test_oscillator_reset_position() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(0, INTERVAL_120_BPM_NS);
for _ in 0..30 {
osc.advance(INTERVAL_120_BPM_NS);
}
let before = osc.next_tick(INTERVAL_120_BPM_NS).expect("running");
assert_eq!(before.beat, 1);
assert_eq!(before.subdivision, 6);
osc.reset_position();
let after = osc.next_tick(INTERVAL_120_BPM_NS).expect("running");
assert_eq!(after.beat, 0);
assert_eq!(after.subdivision, 0);
}
#[test]
fn test_oscillator_spp() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(0, INTERVAL_120_BPM_NS);
osc.set_position_from_spp(7);
let schedule = osc.next_tick(INTERVAL_120_BPM_NS).expect("running");
assert_eq!(schedule.beat, 1);
assert_eq!(schedule.subdivision, 18);
}
#[test]
fn test_slave_clock_not_locked_initially() {
let clock = SlaveClock::new(1_000_000_000);
assert!(!clock.is_locked());
assert_eq!(clock.estimated_bpm(), None);
assert_eq!(clock.next_tick(), None);
}
#[test]
fn test_slave_clock_locks_after_ticks() {
let mut clock = SlaveClock::new(1_000_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
assert!(clock.is_locked());
let bpm = clock.estimated_bpm().expect("should have BPM estimate");
let error_pct = ((bpm - 120.0) / 120.0).abs() * 100.0;
assert!(
error_pct < 1.0,
"expected BPM within 1% of 120, got {bpm} (error: {error_pct:.2}%)"
);
assert!(clock.next_tick().is_some());
}
#[test]
fn test_slave_clock_timeout() {
let timeout_ns = 500_000_000; let mut clock = SlaveClock::new(timeout_ns);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
assert!(clock.is_locked());
let last_tick = 7 * INTERVAL_120_BPM_NS;
let dropout_time = last_tick + timeout_ns + 1;
assert!(clock.check_dropout(dropout_time));
assert!(!clock.is_locked());
}
#[test]
fn test_slave_clock_transport_start_resets() {
let mut clock = SlaveClock::new(1_000_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
for _ in 0..10 {
clock.advance();
}
let now = 8 * INTERVAL_120_BPM_NS;
clock.feed_transport(TransportEvent::Start, now);
let schedule = clock.next_tick().expect("should be running");
assert_eq!(schedule.beat, 0);
assert_eq!(schedule.subdivision, 0);
}
#[test]
fn test_slave_clock_transport_stop_halts() {
let mut clock = SlaveClock::new(1_000_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
assert!(clock.next_tick().is_some());
let now = 8 * INTERVAL_120_BPM_NS;
clock.feed_transport(TransportEvent::Stop, now);
assert_eq!(clock.next_tick(), None);
}
#[test]
fn test_slave_clock_transport_continue_resumes() {
let mut clock = SlaveClock::new(1_000_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
let now = 8 * INTERVAL_120_BPM_NS;
clock.feed_transport(TransportEvent::Stop, now);
assert_eq!(clock.next_tick(), None);
clock.feed_transport(TransportEvent::Continue, now + INTERVAL_120_BPM_NS);
assert!(
clock.next_tick().is_some(),
"should produce ticks after Continue"
);
}
#[test]
fn test_slave_output_jitter_within_budget() {
let mut rng_state: u64 = 42;
let lcg_next = |state: &mut u64| -> i64 {
*state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
let raw = (*state >> 33) as i64; (raw % 3_000_001) - 1_500_000
};
let mut slave = SlaveClock::new(1_000_000_000);
let mut internal_ticks: Vec<u64> = Vec::new();
let mut last_external: u64 = 0;
for i in 0..240u64 {
let ideal = i * INTERVAL_120_BPM_NS;
let jitter = lcg_next(&mut rng_state);
let timestamp = (ideal as i64 + jitter).max(0) as u64;
let timestamp = timestamp.max(last_external + 1);
last_external = timestamp;
slave.feed_clock_byte(timestamp);
if let Some(schedule) = slave.next_tick() {
internal_ticks.push(schedule.next_tick_ns);
slave.advance();
}
}
assert!(
internal_ticks.len() >= 100,
"expected at least 100 internal ticks, got {}",
internal_ticks.len()
);
let mut jitters_ns: Vec<u64> = Vec::new();
for window in internal_ticks.windows(2) {
let actual_interval = window[1].saturating_sub(window[0]);
let jitter = actual_interval.abs_diff(INTERVAL_120_BPM_NS);
jitters_ns.push(jitter);
}
jitters_ns.sort_unstable();
let median_idx = jitters_ns.len() / 2;
let p99_idx = (jitters_ns.len() as f64 * 0.99) as usize;
let p99_idx = p99_idx.min(jitters_ns.len() - 1);
let median_us = jitters_ns[median_idx] / 1_000;
let p99_us = jitters_ns[p99_idx] / 1_000;
eprintln!(
"Jitter test: {} ticks, median = {}us, P99 = {}us",
jitters_ns.len(),
median_us,
p99_us,
);
assert!(
median_us < 500,
"median jitter {median_us}us exceeds 500us threshold"
);
assert!(
p99_us < 2000,
"P99 jitter {p99_us}us exceeds 2000us threshold"
);
}
#[test]
fn test_estimator_not_locked_with_3_ticks() {
let mut estimator = TempoEstimator::new(8);
for i in 0..3 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(
!estimator.is_locked(),
"3 ticks should not lock the estimator"
);
assert_eq!(estimator.estimated_bpm(), None);
assert_eq!(estimator.estimated_interval_ns(), None);
}
#[test]
fn test_estimator_zero_interval_returns_none_bpm() {
let mut estimator = TempoEstimator::new(8);
for _ in 0..4 {
estimator.feed_tick(1_000_000);
}
if estimator.is_locked() {
let bpm = estimator.estimated_bpm();
assert_eq!(bpm, None, "zero interval should yield None BPM");
}
}
#[test]
fn test_estimator_raw_filtered_interval() {
let mut estimator = TempoEstimator::new(8);
assert_eq!(estimator.raw_filtered_interval_ns(), None);
estimator.feed_tick(0);
estimator.feed_tick(INTERVAL_120_BPM_NS);
assert!(!estimator.is_locked());
let raw = estimator.raw_filtered_interval_ns();
assert!(raw.is_some(), "should have raw interval after 2 ticks");
let raw = raw.unwrap();
let error = (raw - INTERVAL_120_BPM_NS as f64).abs();
assert!(
error < 1.0,
"raw interval should be close to the tick interval"
);
}
#[test]
fn test_estimator_no_dropout_before_ticks() {
let mut estimator = TempoEstimator::new(8);
assert!(!estimator.check_dropout(1_000_000_000));
}
#[test]
fn test_estimator_no_dropout_within_window() {
let mut estimator = TempoEstimator::new(8);
for i in 0..8 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(estimator.is_locked());
let last_tick = 7 * INTERVAL_120_BPM_NS;
let within_window = last_tick + 2 * INTERVAL_120_BPM_NS;
assert!(!estimator.check_dropout(within_window));
}
#[test]
fn test_slave_clock_advance_when_not_locked() {
let mut clock = SlaveClock::new(1_000_000_000);
clock.advance();
assert!(!clock.is_locked());
}
#[test]
fn test_slave_clock_feed_spp() {
let mut clock = SlaveClock::new(1_000_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
assert!(clock.is_locked());
clock.feed_spp(8); let schedule = clock.next_tick().expect("should be locked and running");
assert_eq!(schedule.beat, 2);
assert_eq!(schedule.subdivision, 0);
}
#[test]
fn test_slave_clock_continue_before_locked() {
let mut clock = SlaveClock::new(1_000_000_000);
clock.feed_clock_byte(0);
clock.feed_clock_byte(INTERVAL_120_BPM_NS);
assert!(!clock.is_locked());
let now = 2 * INTERVAL_120_BPM_NS;
clock.feed_transport(TransportEvent::Stop, now);
clock.feed_transport(TransportEvent::Continue, now + INTERVAL_120_BPM_NS);
assert!(!clock.is_locked());
}
#[test]
fn test_slave_clock_dropout_resets_oscillator() {
let mut clock = SlaveClock::new(500_000_000);
for i in 0..8 {
clock.feed_clock_byte(i * INTERVAL_120_BPM_NS);
}
assert!(clock.is_locked());
assert!(clock.next_tick().is_some());
let last_tick = 7 * INTERVAL_120_BPM_NS;
let dropout_time = last_tick + 500_000_001; assert!(clock.check_dropout(dropout_time));
assert!(!clock.is_locked());
assert_eq!(clock.next_tick(), None);
}
#[test]
fn test_oscillator_phase_correction() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(1_000_000, INTERVAL_120_BPM_NS);
let schedule_before = osc.next_tick(INTERVAL_120_BPM_NS).unwrap();
let expected_first = 1_000_000 + INTERVAL_120_BPM_NS;
assert_eq!(schedule_before.next_tick_ns, expected_first);
let late_tick = expected_first + 500_000; osc.sync_to_external(late_tick, INTERVAL_120_BPM_NS);
let schedule_after = osc.next_tick(INTERVAL_120_BPM_NS).unwrap();
assert!(
schedule_after.next_tick_ns > expected_first,
"phase correction should shift next tick later for a late external tick"
);
}
#[test]
fn test_oscillator_resume_does_nothing_if_already_running() {
let mut osc = SlaveOscillator::new();
osc.sync_to_external(0, INTERVAL_120_BPM_NS);
let before = osc.next_tick(INTERVAL_120_BPM_NS).unwrap().next_tick_ns;
osc.resume(1_000_000_000, INTERVAL_120_BPM_NS);
let after = osc.next_tick(INTERVAL_120_BPM_NS).unwrap().next_tick_ns;
assert_eq!(
before, after,
"resume should not change next_tick_ns if already running"
);
}
#[test]
fn test_estimator_minimum_window_size() {
let mut estimator = TempoEstimator::new(1);
for i in 0..4 {
estimator.feed_tick(i * INTERVAL_120_BPM_NS);
}
assert!(
estimator.is_locked(),
"estimator with window_size=1 (clamped to 2) should still lock"
);
}
}