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) = *self.history.last().unwrap();
334
335        let time_diff_ms = last_time.saturating_sub(first_time);
336        let frame_diff = last_frames.saturating_sub(first_frames);
337
338        if time_diff_ms > 0 {
339            let expected_frames = (time_diff_ms as f64 / 1000.0) * self.frame_rate.as_float();
340            let drift = (frame_diff as f64 - expected_frames) / expected_frames;
341            self.drift_ppm = (drift * 1_000_000.0) as f32;
342        }
343    }
344
345    fn drift_ppm(&self) -> f32 {
346        self.drift_ppm
347    }
348
349    fn reset(&mut self) {
350        self.reference_frames = 0;
351        self.actual_frames = 0;
352        self.drift_ppm = 0.0;
353        self.history.clear();
354    }
355}
356
357/// Reconciliation strategy for multiple sources
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum ReconciliationStrategy {
360    /// Prefer LTC, fall back to VITC
361    PreferLtc,
362    /// Prefer VITC, fall back to LTC
363    PreferVitc,
364    /// Cross-check both sources
365    CrossCheck,
366    /// Use most recently updated source
367    MostRecent,
368}
369
370/// Synchronization status
371#[derive(Debug, Clone)]
372pub struct SyncStatus {
373    /// Is timecode synchronized
374    pub is_synchronized: bool,
375    /// Is LTC available
376    pub ltc_available: bool,
377    /// Is VITC available
378    pub vitc_available: bool,
379    /// Is jam sync active
380    pub jam_sync_active: bool,
381    /// Clock drift in PPM
382    pub drift_ppm: f32,
383}
384
385/// Genlock synchronizer
386pub struct GenlockSynchronizer {
387    /// Frame rate
388    frame_rate: FrameRate,
389    /// Reference phase
390    reference_phase: f32,
391    /// Current phase
392    current_phase: f32,
393    /// Phase error
394    phase_error: f32,
395    /// Locked status
396    locked: bool,
397}
398
399impl GenlockSynchronizer {
400    /// Create a new genlock synchronizer
401    pub fn new(frame_rate: FrameRate) -> Self {
402        GenlockSynchronizer {
403            frame_rate,
404            reference_phase: 0.0,
405            current_phase: 0.0,
406            phase_error: 0.0,
407            locked: false,
408        }
409    }
410
411    /// Update with reference sync pulse
412    pub fn update_reference(&mut self, phase: f32) {
413        self.reference_phase = phase;
414        self.calculate_phase_error();
415    }
416
417    /// Update with timecode sync
418    pub fn update_timecode(&mut self, timecode: &Timecode) {
419        let frames = timecode.frames as f32;
420        let fps = self.frame_rate.frames_per_second() as f32;
421        self.current_phase = frames / fps;
422        self.calculate_phase_error();
423    }
424
425    /// Calculate phase error
426    fn calculate_phase_error(&mut self) {
427        self.phase_error = self.current_phase - self.reference_phase;
428
429        // Wrap phase error to [-0.5, 0.5]
430        while self.phase_error > 0.5 {
431            self.phase_error -= 1.0;
432        }
433        while self.phase_error < -0.5 {
434            self.phase_error += 1.0;
435        }
436
437        // Update locked status
438        self.locked = self.phase_error.abs() < 0.01;
439    }
440
441    /// Check if locked to reference
442    pub fn is_locked(&self) -> bool {
443        self.locked
444    }
445
446    /// Get phase error
447    pub fn phase_error(&self) -> f32 {
448        self.phase_error
449    }
450
451    /// Get correction in frames
452    pub fn correction_frames(&self) -> i32 {
453        let fps = self.frame_rate.frames_per_second() as f32;
454        (self.phase_error * fps) as i32
455    }
456
457    /// Reset genlock state
458    pub fn reset(&mut self) {
459        self.reference_phase = 0.0;
460        self.current_phase = 0.0;
461        self.phase_error = 0.0;
462        self.locked = false;
463    }
464}
465
466/// Multi-source timecode aggregator
467pub struct TimecodeAggregator {
468    /// Sources
469    sources: Vec<TimecodeSource>,
470    /// Voting strategy
471    strategy: VotingStrategy,
472}
473
474impl TimecodeAggregator {
475    /// Create a new aggregator
476    pub fn new(strategy: VotingStrategy) -> Self {
477        TimecodeAggregator {
478            sources: Vec::new(),
479            strategy,
480        }
481    }
482
483    /// Add a timecode source
484    pub fn add_source(&mut self, name: String, timecode: Timecode, confidence: f32) {
485        self.sources.push(TimecodeSource {
486            name,
487            timecode,
488            confidence,
489        });
490    }
491
492    /// Clear all sources
493    pub fn clear_sources(&mut self) {
494        self.sources.clear();
495    }
496
497    /// Get aggregated timecode
498    pub fn aggregate(&self) -> Option<Timecode> {
499        if self.sources.is_empty() {
500            return None;
501        }
502
503        match self.strategy {
504            VotingStrategy::Unanimous => self.unanimous_vote(),
505            VotingStrategy::Majority => self.majority_vote(),
506            VotingStrategy::HighestConfidence => self.highest_confidence(),
507            VotingStrategy::WeightedAverage => self.weighted_average(),
508        }
509    }
510
511    /// Unanimous vote - all sources must agree
512    fn unanimous_vote(&self) -> Option<Timecode> {
513        if self.sources.is_empty() {
514            return None;
515        }
516
517        let first = &self.sources[0].timecode;
518        for source in &self.sources[1..] {
519            if source.timecode.to_frames() != first.to_frames() {
520                return None;
521            }
522        }
523
524        Some(*first)
525    }
526
527    /// Majority vote
528    fn majority_vote(&self) -> Option<Timecode> {
529        if self.sources.is_empty() {
530            return None;
531        }
532
533        // Count occurrences of each timecode
534        let mut counts: Vec<(u64, usize)> = Vec::new();
535        for source in &self.sources {
536            let frames = source.timecode.to_frames();
537            if let Some(entry) = counts.iter_mut().find(|(f, _)| *f == frames) {
538                entry.1 += 1;
539            } else {
540                counts.push((frames, 1));
541            }
542        }
543
544        // Find majority
545        counts.sort_by(|a, b| b.1.cmp(&a.1));
546        if let Some((frames, _)) = counts.first() {
547            // Return the timecode with the most votes
548            self.sources
549                .iter()
550                .find(|s| s.timecode.to_frames() == *frames)
551                .map(|s| s.timecode)
552        } else {
553            None
554        }
555    }
556
557    /// Highest confidence
558    fn highest_confidence(&self) -> Option<Timecode> {
559        self.sources
560            .iter()
561            .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap())
562            .map(|s| s.timecode)
563    }
564
565    /// Weighted average
566    fn weighted_average(&self) -> Option<Timecode> {
567        if self.sources.is_empty() {
568            return None;
569        }
570
571        let total_weight: f32 = self.sources.iter().map(|s| s.confidence).sum();
572        if total_weight == 0.0 {
573            return None;
574        }
575
576        let weighted_frames: f64 = self
577            .sources
578            .iter()
579            .map(|s| s.timecode.to_frames() as f64 * s.confidence as f64)
580            .sum();
581
582        let avg_frames = (weighted_frames / total_weight as f64) as u64;
583
584        // Use frame rate from first source
585        Timecode::from_frames(avg_frames, FrameRate::Fps25).ok()
586    }
587}
588
589/// Timecode source
590#[derive(Debug, Clone)]
591struct TimecodeSource {
592    #[allow(dead_code)]
593    name: String,
594    timecode: Timecode,
595    confidence: f32,
596}
597
598/// Voting strategy
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum VotingStrategy {
601    /// All sources must agree
602    Unanimous,
603    /// Majority wins
604    Majority,
605    /// Highest confidence wins
606    HighestConfidence,
607    /// Weighted average
608    WeightedAverage,
609}
610
611/// Get current time in milliseconds (mock implementation)
612fn current_time_ms() -> u64 {
613    // In a real implementation, this would use std::time::SystemTime
614    // For now, return a placeholder
615    0
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_synchronizer_creation() {
624        let sync = TimecodeSynchronizer::new(FrameRate::Fps25, ReconciliationStrategy::PreferLtc);
625        assert!(sync.get_timecode().is_none());
626    }
627
628    #[test]
629    fn test_jam_sync() {
630        let mut sync =
631            TimecodeSynchronizer::new(FrameRate::Fps25, ReconciliationStrategy::PreferLtc);
632        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).unwrap();
633
634        sync.jam_sync(tc);
635        assert!(sync.is_jam_synced());
636    }
637
638    #[test]
639    fn test_genlock() {
640        let mut genlock = GenlockSynchronizer::new(FrameRate::Fps25);
641        genlock.update_reference(0.0);
642
643        let tc = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).unwrap();
644        genlock.update_timecode(&tc);
645
646        // Phase for frame 0 is 0.0, which matches reference 0.0, so it should be locked
647        assert!(genlock.is_locked());
648
649        // Test with different phase
650        genlock.update_reference(0.5);
651        let tc2 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).unwrap();
652        genlock.update_timecode(&tc2);
653        // Phase error is now 0.5, so it should not be locked
654        assert!(!genlock.is_locked());
655    }
656
657    #[test]
658    fn test_aggregator() {
659        let mut agg = TimecodeAggregator::new(VotingStrategy::HighestConfidence);
660
661        let tc1 = Timecode::new(1, 0, 0, 0, FrameRate::Fps25).unwrap();
662        let tc2 = Timecode::new(1, 0, 0, 1, FrameRate::Fps25).unwrap();
663
664        agg.add_source("LTC".to_string(), tc1, 0.8);
665        agg.add_source("VITC".to_string(), tc2, 0.9);
666
667        let result = agg.aggregate();
668        assert_eq!(result, Some(tc2));
669    }
670}