Skip to main content

oximedia_timecode/
jam_sync.rs

1#![allow(dead_code)]
2//! Jam-sync controller for locking a local timecode generator to an external
3//! timecode reference (LTC/MTC).
4//!
5//! # Overview
6//!
7//! A [`JamSyncController`] ingests external timecode frames via
8//! [`feed_reference`](JamSyncController::feed_reference).  Once
9//! `lock_threshold` consecutive frames arrive that are exactly one frame apart
10//! (within `tolerance` frames), the controller transitions to
11//! [`Locked`](JamSyncState::Locked) and slaves its internal
12//! [`TimecodeGenerator`] to the reference.
13//!
14//! If the reference disappears (no call to `feed_reference` for more than
15//! `holdover_budget` increments), the controller transitions to
16//! [`Holdover`](JamSyncState::Holdover): the local generator keeps running
17//! from the last known-good position.  Calls to [`output`](JamSyncController::output)
18//! continue to return valid, incrementing timecodes during holdover.
19
20use crate::{timecode_generator::TimecodeGenerator, FrameRate, Timecode, TimecodeError};
21
22/// The synchronisation state of a [`JamSyncController`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum JamSyncState {
25    /// No reference has been received yet (or the controller was just reset).
26    WaitingForReference,
27    /// Candidate frames are arriving and being evaluated; the controller has
28    /// not yet accumulated `lock_threshold` consecutive matching frames.
29    Locking,
30    /// The controller is locked to the external reference.
31    Locked,
32    /// The reference has been lost; the local generator is free-running from
33    /// the last locked position.
34    Holdover,
35}
36
37/// Configuration for a [`JamSyncController`].
38#[derive(Debug, Clone, Copy)]
39pub struct JamSyncConfig {
40    /// Number of consecutive reference frames that must be exactly sequential
41    /// before the controller transitions from `Locking` to `Locked`.
42    pub lock_threshold: usize,
43    /// Maximum difference in frames between consecutive reference inputs
44    /// that is still considered "sequential" (for tolerance of jittery inputs).
45    pub tolerance_frames: u64,
46    /// Number of `output()` calls (frames) that can elapse without a reference
47    /// before the controller transitions from `Locked` to `Holdover`.
48    pub holdover_budget: u64,
49}
50
51impl Default for JamSyncConfig {
52    fn default() -> Self {
53        Self {
54            lock_threshold: 5,
55            tolerance_frames: 2,
56            holdover_budget: 25, // ≈ 1 second at 25 fps
57        }
58    }
59}
60
61/// Controller that synchronises a local timecode generator to an external
62/// timecode reference.
63///
64/// # State machine
65///
66/// ```text
67/// WaitingForReference ──(first feed_reference)──► Locking
68///       ▲                                              │
69///       │              ┌───────────────────────────────┘
70///       │              │  N consecutive sequential frames
71///       │              ▼
72///       │           Locked ◄─────────────────────────────┐
73///       │              │                                  │
74///       │   (reference lost > holdover_budget)    (reference resumes)
75///       │              ▼                                  │
76///       │          Holdover ──────────────────────────────┘
77///       │              │
78///       └──(reset())───┘
79/// ```
80pub struct JamSyncController {
81    /// Current synchronisation state.
82    state: JamSyncState,
83    /// Configuration.
84    config: JamSyncConfig,
85    /// Local free-running generator (used in Locked/Holdover).
86    generator: TimecodeGenerator,
87    /// Frame rate of the reference (and local generator).
88    frame_rate: FrameRate,
89    /// Candidate window: recent reference frames used during `Locking`.
90    candidate_window: Vec<Timecode>,
91    /// Last reference frame received (used to detect holdover).
92    last_reference: Option<Timecode>,
93    /// Number of output() calls since the last feed_reference() call.
94    frames_since_ref: u64,
95    /// Number of consecutive in-tolerance reference frames seen so far.
96    consecutive_count: usize,
97}
98
99impl JamSyncController {
100    /// Create a new controller for the given `frame_rate` using the provided
101    /// `config`.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if `TimecodeGenerator::at_midnight` fails (should not
106    /// occur for well-defined frame rates).
107    pub fn new(frame_rate: FrameRate, config: JamSyncConfig) -> Result<Self, TimecodeError> {
108        let generator = TimecodeGenerator::at_midnight(frame_rate)?;
109        Ok(Self {
110            state: JamSyncState::WaitingForReference,
111            config,
112            generator,
113            frame_rate,
114            candidate_window: Vec::new(),
115            last_reference: None,
116            frames_since_ref: 0,
117            consecutive_count: 0,
118        })
119    }
120
121    /// Create a controller with default configuration.
122    ///
123    /// # Errors
124    ///
125    /// See [`new`](Self::new).
126    pub fn with_default_config(frame_rate: FrameRate) -> Result<Self, TimecodeError> {
127        Self::new(frame_rate, JamSyncConfig::default())
128    }
129
130    /// Current synchronisation state.
131    pub fn state(&self) -> JamSyncState {
132        self.state
133    }
134
135    /// Feed an incoming reference timecode frame.
136    ///
137    /// This drives the state machine forward:
138    /// * `WaitingForReference` → `Locking` on the first call.
139    /// * `Locking` → `Locked` after `lock_threshold` consecutive sequential
140    ///   frames.
141    /// * `Holdover` → `Locked` (immediate re-lock) when a sequential frame
142    ///   arrives.
143    pub fn feed_reference(&mut self, tc: Timecode) {
144        self.frames_since_ref = 0;
145
146        match self.state {
147            JamSyncState::WaitingForReference => {
148                self.last_reference = Some(tc);
149                self.consecutive_count = 1;
150                self.state = JamSyncState::Locking;
151            }
152
153            JamSyncState::Locking => {
154                if self.is_sequential(tc) {
155                    self.consecutive_count += 1;
156                    if self.consecutive_count >= self.config.lock_threshold {
157                        // Lock acquired — jam the local generator to this position
158                        self.generator.reset_to(tc);
159                        // Advance by one so the *next* output() is already ahead
160                        let _ = self.generator.next();
161                        self.state = JamSyncState::Locked;
162                    }
163                } else {
164                    // Non-sequential → restart counting
165                    self.consecutive_count = 1;
166                }
167                self.last_reference = Some(tc);
168            }
169
170            JamSyncState::Locked => {
171                // Re-jam if the reference drifts more than tolerance
172                if !self.is_sequential(tc) {
173                    // Slip — re-jam immediately to stay frame-accurate
174                    self.generator.reset_to(tc);
175                    let _ = self.generator.next();
176                }
177                self.last_reference = Some(tc);
178            }
179
180            JamSyncState::Holdover => {
181                // Any sequential or near-sequential frame re-locks
182                self.generator.reset_to(tc);
183                let _ = self.generator.next();
184                self.last_reference = Some(tc);
185                self.consecutive_count = 1;
186                self.state = JamSyncState::Locked;
187            }
188        }
189    }
190
191    /// Return the current output timecode.
192    ///
193    /// In `Locked` or `Holdover` state the local generator advances by one
194    /// frame each call.  In `WaitingForReference` or `Locking` the generator
195    /// is frozen until lock is acquired.
196    ///
197    /// Calling `output()` also updates the holdover counter: if
198    /// `frames_since_ref > holdover_budget` while in `Locked` state the
199    /// controller transitions to `Holdover`.
200    pub fn output(&mut self) -> Timecode {
201        // Update holdover counter
202        self.frames_since_ref += 1;
203
204        match self.state {
205            JamSyncState::Locked => {
206                if self.frames_since_ref > self.config.holdover_budget {
207                    self.state = JamSyncState::Holdover;
208                }
209                self.generator.next()
210            }
211            JamSyncState::Holdover => self.generator.next(),
212            // Not yet locked: peek without advancing
213            _ => self.generator.peek(),
214        }
215    }
216
217    /// Reset the controller to `WaitingForReference` state.
218    ///
219    /// The local generator is reset to midnight.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if resetting the generator fails.
224    pub fn reset(&mut self) -> Result<(), TimecodeError> {
225        self.state = JamSyncState::WaitingForReference;
226        self.last_reference = None;
227        self.frames_since_ref = 0;
228        self.consecutive_count = 0;
229        self.candidate_window.clear();
230        self.generator.reset()
231    }
232
233    /// Force the controller into `Holdover` state (e.g. reference cable
234    /// disconnected).
235    pub fn enter_holdover(&mut self) {
236        if self.state == JamSyncState::Locked {
237            self.state = JamSyncState::Holdover;
238        }
239    }
240
241    /// Number of `output()` calls since the last `feed_reference()` call.
242    pub fn frames_since_reference(&self) -> u64 {
243        self.frames_since_ref
244    }
245
246    // ------------------------------------------------------------------
247    // Internal helpers
248    // ------------------------------------------------------------------
249
250    /// Return `true` if `incoming` is "sequential" relative to the last
251    /// reference, i.e. `|incoming.to_frames() - last.to_frames() - 1| <=
252    /// tolerance`.
253    fn is_sequential(&self, incoming: Timecode) -> bool {
254        match self.last_reference {
255            None => false,
256            Some(last) => {
257                let expected = last.to_frames() + 1;
258                let actual = incoming.to_frames();
259                // Use saturating arithmetic to avoid underflow
260                let diff = if actual >= expected {
261                    actual - expected
262                } else {
263                    expected - actual
264                };
265                diff <= self.config.tolerance_frames
266            }
267        }
268    }
269}
270
271// ============================================================================
272// Tests
273// ============================================================================
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn make_ctrl() -> JamSyncController {
280        JamSyncController::with_default_config(FrameRate::Fps25).expect("ok")
281    }
282
283    /// Build a sequence of `n` consecutive timecodes starting at `start`.
284    fn seq(start: Timecode, n: usize) -> Vec<Timecode> {
285        let mut v = Vec::with_capacity(n);
286        let mut cur = start;
287        for _ in 0..n {
288            v.push(cur);
289            let _ = cur.increment();
290        }
291        v
292    }
293
294    #[test]
295    fn test_initial_state_is_waiting() {
296        let ctrl = make_ctrl();
297        assert_eq!(ctrl.state(), JamSyncState::WaitingForReference);
298    }
299
300    #[test]
301    fn test_first_feed_transitions_to_locking() {
302        let mut ctrl = make_ctrl();
303        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
304        ctrl.feed_reference(tc);
305        assert_eq!(ctrl.state(), JamSyncState::Locking);
306    }
307
308    #[test]
309    fn test_lock_acquired_after_threshold() {
310        let mut ctrl = make_ctrl();
311        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
312        for tc in seq(start, ctrl.config.lock_threshold) {
313            ctrl.feed_reference(tc);
314        }
315        assert_eq!(ctrl.state(), JamSyncState::Locked);
316    }
317
318    #[test]
319    fn test_lock_not_acquired_before_threshold() {
320        let mut ctrl = make_ctrl();
321        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
322        // Feed one fewer than threshold
323        for tc in seq(start, ctrl.config.lock_threshold - 1) {
324            ctrl.feed_reference(tc);
325        }
326        assert_eq!(ctrl.state(), JamSyncState::Locking);
327    }
328
329    #[test]
330    fn test_non_sequential_resets_lock_count() {
331        let mut ctrl = make_ctrl();
332        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
333        // Feed threshold - 1 sequential frames
334        for tc in seq(start, ctrl.config.lock_threshold - 1) {
335            ctrl.feed_reference(tc);
336        }
337        // Feed a non-sequential frame (jump by 100)
338        let jump = Timecode::new(0, 0, 10, 0, FrameRate::Fps25).expect("valid");
339        ctrl.feed_reference(jump);
340        assert_eq!(ctrl.state(), JamSyncState::Locking);
341        // consecutive_count should be back to 1
342        assert_eq!(ctrl.consecutive_count, 1);
343    }
344
345    #[test]
346    fn test_output_tracks_reference_after_lock() {
347        let mut ctrl = make_ctrl();
348        let start = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid");
349        for tc in seq(start, ctrl.config.lock_threshold) {
350            ctrl.feed_reference(tc);
351        }
352        assert_eq!(ctrl.state(), JamSyncState::Locked);
353        // After lock, output should be advancing from (start + threshold)
354        let out = ctrl.output();
355        let expected_frames = start.to_frames() + ctrl.config.lock_threshold as u64;
356        assert_eq!(out.to_frames(), expected_frames);
357    }
358
359    #[test]
360    fn test_holdover_triggered_after_budget_exceeded() {
361        let budget = 5u64;
362        let config = JamSyncConfig {
363            lock_threshold: 3,
364            tolerance_frames: 2,
365            holdover_budget: budget,
366        };
367        let mut ctrl = JamSyncController::new(FrameRate::Fps25, config).expect("ok");
368        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
369        for tc in seq(start, ctrl.config.lock_threshold) {
370            ctrl.feed_reference(tc);
371        }
372        assert_eq!(ctrl.state(), JamSyncState::Locked);
373        // Exhaust the holdover budget by calling output() without feeding
374        for _ in 0..=budget {
375            ctrl.output();
376        }
377        assert_eq!(ctrl.state(), JamSyncState::Holdover);
378    }
379
380    #[test]
381    fn test_holdover_keeps_advancing() {
382        let mut ctrl = make_ctrl();
383        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
384        for tc in seq(start, ctrl.config.lock_threshold) {
385            ctrl.feed_reference(tc);
386        }
387        ctrl.enter_holdover();
388        assert_eq!(ctrl.state(), JamSyncState::Holdover);
389        let f0 = ctrl.output().to_frames();
390        let f1 = ctrl.output().to_frames();
391        assert_eq!(f1, f0 + 1, "generator must keep advancing in holdover");
392    }
393
394    #[test]
395    fn test_re_lock_from_holdover() {
396        let mut ctrl = make_ctrl();
397        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
398        for tc in seq(start, ctrl.config.lock_threshold) {
399            ctrl.feed_reference(tc);
400        }
401        ctrl.enter_holdover();
402        // Feed one new reference frame → immediate re-lock
403        let new_ref = Timecode::new(0, 1, 0, 0, FrameRate::Fps25).expect("valid");
404        ctrl.feed_reference(new_ref);
405        assert_eq!(ctrl.state(), JamSyncState::Locked);
406    }
407
408    #[test]
409    fn test_reset_returns_to_waiting() {
410        let mut ctrl = make_ctrl();
411        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
412        for tc in seq(start, ctrl.config.lock_threshold) {
413            ctrl.feed_reference(tc);
414        }
415        ctrl.reset().expect("reset ok");
416        assert_eq!(ctrl.state(), JamSyncState::WaitingForReference);
417    }
418
419    #[test]
420    fn test_output_frozen_while_locking() {
421        let mut ctrl = make_ctrl();
422        let tc = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
423        ctrl.feed_reference(tc);
424        assert_eq!(ctrl.state(), JamSyncState::Locking);
425        // output() should not advance while locking
426        let o1 = ctrl.output();
427        let o2 = ctrl.output();
428        assert_eq!(o1, o2, "output must be frozen during locking");
429    }
430
431    #[test]
432    fn test_frames_since_reference_counter() {
433        let mut ctrl = make_ctrl();
434        let start = Timecode::new(0, 0, 0, 0, FrameRate::Fps25).expect("valid");
435        for tc in seq(start, ctrl.config.lock_threshold) {
436            ctrl.feed_reference(tc);
437        }
438        // After lock, call output 3 times without feeding
439        for _ in 0..3 {
440            ctrl.output();
441        }
442        assert_eq!(ctrl.frames_since_reference(), 3);
443        // Feeding a new frame resets it
444        let new_tc = Timecode::new(0, 0, 5, 0, FrameRate::Fps25).expect("valid");
445        ctrl.feed_reference(new_tc);
446        assert_eq!(ctrl.frames_since_reference(), 0);
447    }
448}