Skip to main content

oximedia_timecode/
sync.rs

1//! Timecode Synchronization
2//!
3//! This module provides synchronization between multiple timecode sources:
4//! - LTC and VITC cross-checking
5//! - Jam sync for timecode generation
6//! - Clock drift correction
7//! - Multi-source reconciliation
8//! - Genlock synchronization
9
10use crate::{FrameRate, Timecode, TimecodeError};
11
12/// Timecode synchronizer
13pub struct TimecodeSynchronizer {
14    /// Reference frame rate
15    frame_rate: FrameRate,
16    /// Current synchronized timecode
17    current_timecode: Option<Timecode>,
18    /// LTC source state
19    ltc_state: SourceState,
20    /// VITC source state
21    vitc_state: SourceState,
22    /// Jam sync state
23    jam_sync: Option<JamSyncState>,
24    /// Drift corrector
25    drift_corrector: DriftCorrector,
26    /// Reconciliation strategy
27    strategy: ReconciliationStrategy,
28}
29
30impl TimecodeSynchronizer {
31    /// Create a new synchronizer
32    pub fn new(frame_rate: FrameRate, strategy: ReconciliationStrategy) -> Self {
33        TimecodeSynchronizer {
34            frame_rate,
35            current_timecode: None,
36            ltc_state: SourceState::new("LTC"),
37            vitc_state: SourceState::new("VITC"),
38            jam_sync: None,
39            drift_corrector: DriftCorrector::new(frame_rate),
40            strategy,
41        }
42    }
43
44    /// Update with LTC timecode
45    pub fn update_ltc(&mut self, timecode: Timecode) -> Result<(), TimecodeError> {
46        self.ltc_state.update(timecode);
47        self.reconcile()
48    }
49
50    /// Update with VITC timecode
51    pub fn update_vitc(&mut self, timecode: Timecode) -> Result<(), TimecodeError> {
52        self.vitc_state.update(timecode);
53        self.reconcile()
54    }
55
56    /// Get the current synchronized timecode
57    pub fn get_timecode(&self) -> Option<Timecode> {
58        self.current_timecode
59    }
60
61    /// Enable jam sync from a reference timecode
62    pub fn jam_sync(&mut self, reference: Timecode) {
63        self.jam_sync = Some(JamSyncState::new(reference, self.frame_rate));
64        self.current_timecode = Some(reference);
65    }
66
67    /// Disable jam sync
68    pub fn disable_jam_sync(&mut self) {
69        self.jam_sync = None;
70    }
71
72    /// Check if jam sync is active
73    pub fn is_jam_synced(&self) -> bool {
74        self.jam_sync.is_some()
75    }
76
77    /// Reconcile timecodes from multiple sources
78    fn reconcile(&mut self) -> Result<(), TimecodeError> {
79        let ltc_tc = self.ltc_state.last_timecode();
80        let vitc_tc = self.vitc_state.last_timecode();
81
82        let new_timecode = match self.strategy {
83            ReconciliationStrategy::PreferLtc => ltc_tc.or(vitc_tc),
84            ReconciliationStrategy::PreferVitc => vitc_tc.or(ltc_tc),
85            ReconciliationStrategy::CrossCheck => self.cross_check_timecodes(ltc_tc, vitc_tc),
86            ReconciliationStrategy::MostRecent => self.select_most_recent(ltc_tc, vitc_tc),
87        };
88
89        if let Some(tc) = new_timecode {
90            // Apply drift correction
91            let corrected = self.drift_corrector.correct(tc)?;
92            self.current_timecode = Some(corrected);
93        }
94
95        Ok(())
96    }
97
98    /// Cross-check timecodes and select the most reliable
99    fn cross_check_timecodes(
100        &self,
101        ltc: Option<Timecode>,
102        vitc: Option<Timecode>,
103    ) -> Option<Timecode> {
104        match (ltc, vitc) {
105            (Some(ltc_tc), Some(vitc_tc)) => {
106                // Check if timecodes match (within tolerance)
107                let ltc_frames = ltc_tc.to_frames();
108                let vitc_frames = vitc_tc.to_frames();
109                let diff = (ltc_frames as i64 - vitc_frames as i64).abs();
110
111                if diff <= 2 {
112                    // Timecodes match - prefer LTC for continuous playback
113                    Some(ltc_tc)
114                } else {
115                    // Mismatch - prefer VITC at low speeds, LTC at normal speed
116                    if self.ltc_state.is_reliable() {
117                        Some(ltc_tc)
118                    } else {
119                        Some(vitc_tc)
120                    }
121                }
122            }
123            (Some(tc), None) | (None, Some(tc)) => Some(tc),
124            (None, None) => None,
125        }
126    }
127
128    /// Select the most recently updated timecode
129    fn select_most_recent(
130        &self,
131        ltc: Option<Timecode>,
132        vitc: Option<Timecode>,
133    ) -> Option<Timecode> {
134        let ltc_age = self.ltc_state.age_ms();
135        let vitc_age = self.vitc_state.age_ms();
136
137        match (ltc, vitc) {
138            (Some(ltc_tc), Some(_vitc_tc)) => {
139                if ltc_age < vitc_age {
140                    Some(ltc_tc)
141                } else {
142                    Some(_vitc_tc)
143                }
144            }
145            (Some(tc), None) | (None, Some(tc)) => Some(tc),
146            (None, None) => None,
147        }
148    }
149
150    /// Reset synchronizer state
151    pub fn reset(&mut self) {
152        self.current_timecode = None;
153        self.ltc_state.reset();
154        self.vitc_state.reset();
155        self.jam_sync = None;
156        self.drift_corrector.reset();
157    }
158
159    /// Get synchronization status
160    pub fn status(&self) -> SyncStatus {
161        SyncStatus {
162            is_synchronized: self.current_timecode.is_some(),
163            ltc_available: self.ltc_state.is_available(),
164            vitc_available: self.vitc_state.is_available(),
165            jam_sync_active: self.jam_sync.is_some(),
166            drift_ppm: self.drift_corrector.drift_ppm(),
167        }
168    }
169}
170
171/// Source state tracking
172struct SourceState {
173    /// Source name
174    #[allow(dead_code)]
175    name: String,
176    /// Last timecode received
177    last_timecode: Option<Timecode>,
178    /// Timestamp of last update (in milliseconds)
179    last_update_ms: u64,
180    /// Reliability score (0.0 to 1.0)
181    reliability: f32,
182    /// Error count
183    #[allow(dead_code)]
184    error_count: u32,
185}
186
187impl SourceState {
188    fn new(name: &str) -> Self {
189        SourceState {
190            name: name.to_string(),
191            last_timecode: None,
192            last_update_ms: 0,
193            reliability: 0.0,
194            error_count: 0,
195        }
196    }
197
198    fn update(&mut self, timecode: Timecode) {
199        // Validate timecode continuity
200        if let Some(last) = self.last_timecode {
201            let expected_frames = last.to_frames() + 1;
202            let actual_frames = timecode.to_frames();
203
204            if (expected_frames as i64 - actual_frames as i64).abs() > 5 {
205                self.error_count += 1;
206                self.reliability = (self.reliability - 0.1).max(0.0);
207            } else {
208                self.reliability = (self.reliability + 0.1).min(1.0);
209            }
210        }
211
212        self.last_timecode = Some(timecode);
213        self.last_update_ms = current_time_ms();
214    }
215
216    fn last_timecode(&self) -> Option<Timecode> {
217        self.last_timecode
218    }
219
220    fn is_available(&self) -> bool {
221        self.last_timecode.is_some() && self.age_ms() < 1000
222    }
223
224    fn is_reliable(&self) -> bool {
225        self.reliability > 0.7
226    }
227
228    fn age_ms(&self) -> u64 {
229        current_time_ms().saturating_sub(self.last_update_ms)
230    }
231
232    fn reset(&mut self) {
233        self.last_timecode = None;
234        self.last_update_ms = 0;
235        self.reliability = 0.0;
236        self.error_count = 0;
237    }
238}
239
240/// Jam sync state
241#[allow(dead_code)]
242struct JamSyncState {
243    /// Reference timecode
244    reference: Timecode,
245    /// Frame rate
246    frame_rate: FrameRate,
247    /// Start time (in milliseconds)
248    start_time_ms: u64,
249    /// Accumulated frames since start
250    accumulated_frames: u64,
251}
252
253impl JamSyncState {
254    fn new(reference: Timecode, frame_rate: FrameRate) -> Self {
255        JamSyncState {
256            reference,
257            frame_rate,
258            start_time_ms: current_time_ms(),
259            accumulated_frames: 0,
260        }
261    }
262
263    /// Generate current timecode based on elapsed time
264    #[allow(dead_code)]
265    fn generate_current(&mut self) -> Result<Timecode, TimecodeError> {
266        let elapsed_ms = current_time_ms().saturating_sub(self.start_time_ms);
267        let fps = self.frame_rate.as_float();
268        let frames = ((elapsed_ms as f64 / 1000.0) * fps) as u64;
269
270        let total_frames = self.reference.to_frames() + frames;
271        Timecode::from_frames(total_frames, self.frame_rate)
272    }
273}
274
275/// Drift corrector
276struct DriftCorrector {
277    /// Frame rate
278    frame_rate: FrameRate,
279    /// Reference clock (in frames)
280    reference_frames: u64,
281    /// Actual frames received
282    actual_frames: u64,
283    /// Drift in PPM (parts per million)
284    drift_ppm: f32,
285    /// History for drift calculation
286    history: Vec<(u64, u64)>, // (timestamp_ms, frames)
287}
288
289impl DriftCorrector {
290    fn new(frame_rate: FrameRate) -> Self {
291        DriftCorrector {
292            frame_rate,
293            reference_frames: 0,
294            actual_frames: 0,
295            drift_ppm: 0.0,
296            history: Vec::new(),
297        }
298    }
299
300    /// Correct timecode for drift
301    fn correct(&mut self, timecode: Timecode) -> Result<Timecode, TimecodeError> {
302        let frames = timecode.to_frames();
303        let timestamp = current_time_ms();
304
305        // Add to history
306        self.history.push((timestamp, frames));
307        if self.history.len() > 100 {
308            self.history.remove(0);
309        }
310
311        // Calculate drift if we have enough history
312        if self.history.len() >= 10 {
313            self.calculate_drift();
314        }
315
316        // Apply correction if drift is significant
317        if self.drift_ppm.abs() > 100.0 {
318            let correction_frames = (frames as f32 * self.drift_ppm / 1_000_000.0) as i64;
319            let corrected_frames = (frames as i64 + correction_frames).max(0) as u64;
320            Timecode::from_frames(corrected_frames, self.frame_rate)
321        } else {
322            Ok(timecode)
323        }
324    }
325
326    /// Calculate drift from history
327    fn calculate_drift(&mut self) {
328        if self.history.len() < 2 {
329            return;
330        }
331
332        let (first_time, first_frames) = self.history[0];
333        let (last_time, last_frames) = match self.history.last() {
334            Some(v) => *v,
335            None => return,
336        };
337
338        let time_diff_ms = last_time.saturating_sub(first_time);
339        let frame_diff = last_frames.saturating_sub(first_frames);
340
341        if time_diff_ms > 0 {
342            let expected_frames = (time_diff_ms as f64 / 1000.0) * self.frame_rate.as_float();
343            let drift = (frame_diff as f64 - expected_frames) / expected_frames;
344            self.drift_ppm = (drift * 1_000_000.0) as f32;
345        }
346    }
347
348    fn drift_ppm(&self) -> f32 {
349        self.drift_ppm
350    }
351
352    fn reset(&mut self) {
353        self.reference_frames = 0;
354        self.actual_frames = 0;
355        self.drift_ppm = 0.0;
356        self.history.clear();
357    }
358}
359
360/// Reconciliation strategy for multiple sources
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum ReconciliationStrategy {
363    /// Prefer LTC, fall back to VITC
364    PreferLtc,
365    /// Prefer VITC, fall back to LTC
366    PreferVitc,
367    /// Cross-check both sources
368    CrossCheck,
369    /// Use most recently updated source
370    MostRecent,
371}
372
373/// Synchronization status
374#[derive(Debug, Clone)]
375pub struct SyncStatus {
376    /// Is timecode synchronized
377    pub is_synchronized: bool,
378    /// Is LTC available
379    pub ltc_available: bool,
380    /// Is VITC available
381    pub vitc_available: bool,
382    /// Is jam sync active
383    pub jam_sync_active: bool,
384    /// Clock drift in PPM
385    pub drift_ppm: f32,
386}
387
388/// Genlock synchronizer
389pub struct GenlockSynchronizer {
390    /// Frame rate
391    frame_rate: FrameRate,
392    /// Reference phase
393    reference_phase: f32,
394    /// Current phase
395    current_phase: f32,
396    /// Phase error
397    phase_error: f32,
398    /// Locked status
399    locked: bool,
400}
401
402impl GenlockSynchronizer {
403    /// Create a new genlock synchronizer
404    pub fn new(frame_rate: FrameRate) -> Self {
405        GenlockSynchronizer {
406            frame_rate,
407            reference_phase: 0.0,
408            current_phase: 0.0,
409            phase_error: 0.0,
410            locked: false,
411        }
412    }
413
414    /// Update with reference sync pulse
415    pub fn update_reference(&mut self, phase: f32) {
416        self.reference_phase = phase;
417        self.calculate_phase_error();
418    }
419
420    /// Update with timecode sync
421    pub fn update_timecode(&mut self, timecode: &Timecode) {
422        let frames = timecode.frames as f32;
423        let fps = self.frame_rate.frames_per_second() as f32;
424        self.current_phase = frames / fps;
425        self.calculate_phase_error();
426    }
427
428    /// Calculate phase error
429    fn calculate_phase_error(&mut self) {
430        self.phase_error = self.current_phase - self.reference_phase;
431
432        // Wrap phase error to [-0.5, 0.5]
433        while self.phase_error > 0.5 {
434            self.phase_error -= 1.0;
435        }
436        while self.phase_error < -0.5 {
437            self.phase_error += 1.0;
438        }
439
440        // Update locked status
441        self.locked = self.phase_error.abs() < 0.01;
442    }
443
444    /// Check if locked to reference
445    pub fn is_locked(&self) -> bool {
446        self.locked
447    }
448
449    /// Get phase error
450    pub fn phase_error(&self) -> f32 {
451        self.phase_error
452    }
453
454    /// Get correction in frames
455    pub fn correction_frames(&self) -> i32 {
456        let fps = self.frame_rate.frames_per_second() as f32;
457        (self.phase_error * fps) as i32
458    }
459
460    /// Reset genlock state
461    pub fn reset(&mut self) {
462        self.reference_phase = 0.0;
463        self.current_phase = 0.0;
464        self.phase_error = 0.0;
465        self.locked = false;
466    }
467}
468
469/// Multi-source timecode aggregator
470pub struct TimecodeAggregator {
471    /// Sources
472    sources: Vec<TimecodeSource>,
473    /// Voting strategy
474    strategy: VotingStrategy,
475}
476
477impl TimecodeAggregator {
478    /// Create a new aggregator
479    pub fn new(strategy: VotingStrategy) -> Self {
480        TimecodeAggregator {
481            sources: Vec::new(),
482            strategy,
483        }
484    }
485
486    /// Add a timecode source
487    pub fn add_source(&mut self, name: String, timecode: Timecode, confidence: f32) {
488        self.sources.push(TimecodeSource {
489            name,
490            timecode,
491            confidence,
492        });
493    }
494
495    /// Clear all sources
496    pub fn clear_sources(&mut self) {
497        self.sources.clear();
498    }
499
500    /// Get aggregated timecode
501    pub fn aggregate(&self) -> Option<Timecode> {
502        if self.sources.is_empty() {
503            return None;
504        }
505
506        match self.strategy {
507            VotingStrategy::Unanimous => self.unanimous_vote(),
508            VotingStrategy::Majority => self.majority_vote(),
509            VotingStrategy::HighestConfidence => self.highest_confidence(),
510            VotingStrategy::WeightedAverage => self.weighted_average(),
511        }
512    }
513
514    /// Unanimous vote - all sources must agree
515    fn unanimous_vote(&self) -> Option<Timecode> {
516        if self.sources.is_empty() {
517            return None;
518        }
519
520        let first = &self.sources[0].timecode;
521        for source in &self.sources[1..] {
522            if source.timecode.to_frames() != first.to_frames() {
523                return None;
524            }
525        }
526
527        Some(*first)
528    }
529
530    /// Majority vote
531    fn majority_vote(&self) -> Option<Timecode> {
532        if self.sources.is_empty() {
533            return None;
534        }
535
536        // Count occurrences of each timecode
537        let mut counts: Vec<(u64, usize)> = Vec::new();
538        for source in &self.sources {
539            let frames = source.timecode.to_frames();
540            if let Some(entry) = counts.iter_mut().find(|(f, _)| *f == frames) {
541                entry.1 += 1;
542            } else {
543                counts.push((frames, 1));
544            }
545        }
546
547        // Find majority
548        counts.sort_by(|a, b| b.1.cmp(&a.1));
549        if let Some((frames, _)) = counts.first() {
550            // Return the timecode with the most votes
551            self.sources
552                .iter()
553                .find(|s| s.timecode.to_frames() == *frames)
554                .map(|s| s.timecode)
555        } else {
556            None
557        }
558    }
559
560    /// Highest confidence
561    fn highest_confidence(&self) -> Option<Timecode> {
562        self.sources
563            .iter()
564            .max_by(|a, b| {
565                a.confidence
566                    .partial_cmp(&b.confidence)
567                    .unwrap_or(std::cmp::Ordering::Equal)
568            })
569            .map(|s| s.timecode)
570    }
571
572    /// Weighted average
573    fn weighted_average(&self) -> Option<Timecode> {
574        if self.sources.is_empty() {
575            return None;
576        }
577
578        let total_weight: f32 = self.sources.iter().map(|s| s.confidence).sum();
579        if total_weight == 0.0 {
580            return None;
581        }
582
583        let weighted_frames: f64 = self
584            .sources
585            .iter()
586            .map(|s| s.timecode.to_frames() as f64 * s.confidence as f64)
587            .sum();
588
589        let avg_frames = (weighted_frames / total_weight as f64) as u64;
590
591        // Use frame rate from first source
592        Timecode::from_frames(avg_frames, FrameRate::Fps25).ok()
593    }
594}
595
596/// Timecode source
597#[derive(Debug, Clone)]
598struct TimecodeSource {
599    #[allow(dead_code)]
600    name: String,
601    timecode: Timecode,
602    confidence: f32,
603}
604
605/// Voting strategy
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub enum VotingStrategy {
608    /// All sources must agree
609    Unanimous,
610    /// Majority wins
611    Majority,
612    /// Highest confidence wins
613    HighestConfidence,
614    /// Weighted average
615    WeightedAverage,
616}
617
618/// Get current time in milliseconds since the Unix epoch.
619fn current_time_ms() -> u64 {
620    use std::time::{SystemTime, UNIX_EPOCH};
621    SystemTime::now()
622        .duration_since(UNIX_EPOCH)
623        .map(|d| d.as_millis() as u64)
624        .unwrap_or(0)
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_synchronizer_creation() {
633        let sync = TimecodeSynchronizer::new(FrameRate::Fps25, ReconciliationStrategy::PreferLtc);
634        assert!(sync.get_timecode().is_none());
635    }
636
637    #[test]
638    fn test_jam_sync() {
639        let mut sync =
640            TimecodeSynchronizer::new(FrameRate::Fps25, ReconciliationStrategy::PreferLtc);
641        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
642
643        sync.jam_sync(tc);
644        assert!(sync.is_jam_synced());
645    }
646
647    #[test]
648    fn test_genlock() {
649        let mut genlock = GenlockSynchronizer::new(FrameRate::Fps25);
650        genlock.update_reference(0.0);
651
652        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
653        genlock.update_timecode(&tc);
654
655        // Phase for frame 0 is 0.0, which matches reference 0.0, so it should be locked
656        assert!(genlock.is_locked());
657
658        // Test with different phase
659        genlock.update_reference(0.5);
660        let tc2 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
661        genlock.update_timecode(&tc2);
662        // Phase error is now 0.5, so it should not be locked
663        assert!(!genlock.is_locked());
664    }
665
666    #[test]
667    fn test_aggregator() {
668        let mut agg = TimecodeAggregator::new(VotingStrategy::HighestConfidence);
669
670        let tc1 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).expect("valid timecode");
671        let tc2 = Timecode::new(1, 0, 0, 1, FrameRate::Fps25).expect("valid timecode");
672
673        agg.add_source("LTC".to_string(), tc1, 0.8);
674        agg.add_source("VITC".to_string(), tc2, 0.9);
675
676        let result = agg.aggregate();
677        assert_eq!(result, Some(tc2));
678    }
679}