Skip to main content

oximedia_timecode/
tc_drift.rs

1#![allow(dead_code)]
2//! Timecode drift detection and correction.
3//!
4//! Detects clock drift between timecode sources and a reference clock,
5//! and provides correction strategies for maintaining synchronization
6//! in long-form recordings.
7
8use crate::{FrameRate, Timecode, TimecodeError};
9
10/// A drift measurement sample: the observed timecode vs expected timecode at a given wall time.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct DriftSample {
13    /// Wall-clock time in seconds since the measurement started.
14    pub wall_time_secs: f64,
15    /// Observed frame count from the timecode source.
16    pub observed_frames: u64,
17    /// Expected frame count from the reference clock.
18    pub expected_frames: u64,
19}
20
21impl DriftSample {
22    /// Creates a new drift sample.
23    pub fn new(wall_time_secs: f64, observed_frames: u64, expected_frames: u64) -> Self {
24        Self {
25            wall_time_secs,
26            observed_frames,
27            expected_frames,
28        }
29    }
30
31    /// Returns the drift in frames (observed - expected). Positive means ahead.
32    #[allow(clippy::cast_precision_loss)]
33    pub fn drift_frames(&self) -> i64 {
34        self.observed_frames as i64 - self.expected_frames as i64
35    }
36
37    /// Returns the drift as a fraction of the expected frames.
38    #[allow(clippy::cast_precision_loss)]
39    pub fn drift_ratio(&self) -> f64 {
40        if self.expected_frames == 0 {
41            return 0.0;
42        }
43        (self.observed_frames as f64 - self.expected_frames as f64) / self.expected_frames as f64
44    }
45}
46
47/// Drift correction strategy.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum CorrectionStrategy {
50    /// No correction applied.
51    None,
52    /// Drop or repeat frames to re-sync.
53    FrameDropRepeat,
54    /// Adjust the effective frame rate by a small PPM offset.
55    RateAdjust,
56    /// Phase-shift: apply a one-time frame offset.
57    PhaseShift,
58}
59
60/// Drift analysis result.
61#[derive(Debug, Clone)]
62pub struct DriftAnalysis {
63    /// Average drift in frames per hour.
64    pub drift_frames_per_hour: f64,
65    /// Drift rate in parts per million (PPM).
66    pub drift_ppm: f64,
67    /// Maximum absolute drift observed in frames.
68    pub max_drift_frames: i64,
69    /// Whether drift is within acceptable tolerance.
70    pub within_tolerance: bool,
71    /// Number of samples analyzed.
72    pub sample_count: usize,
73    /// Recommended correction strategy.
74    pub recommended_strategy: CorrectionStrategy,
75}
76
77/// Configuration for drift detection.
78#[derive(Debug, Clone)]
79pub struct DriftConfig {
80    /// Frame rate of the timecode source.
81    pub frame_rate: FrameRate,
82    /// Maximum acceptable drift in frames before correction is recommended.
83    pub tolerance_frames: u32,
84    /// Minimum number of samples before analysis is valid.
85    pub min_samples: usize,
86    /// PPM threshold above which rate-adjust is recommended.
87    pub ppm_threshold: f64,
88}
89
90impl DriftConfig {
91    /// Creates a default configuration for the given frame rate.
92    pub fn new(frame_rate: FrameRate) -> Self {
93        Self {
94            frame_rate,
95            tolerance_frames: 2,
96            min_samples: 3,
97            ppm_threshold: 100.0,
98        }
99    }
100
101    /// Sets the tolerance in frames.
102    pub fn with_tolerance(mut self, frames: u32) -> Self {
103        self.tolerance_frames = frames;
104        self
105    }
106
107    /// Sets the minimum sample count.
108    pub fn with_min_samples(mut self, n: usize) -> Self {
109        self.min_samples = n;
110        self
111    }
112
113    /// Sets the PPM threshold.
114    pub fn with_ppm_threshold(mut self, ppm: f64) -> Self {
115        self.ppm_threshold = ppm;
116        self
117    }
118}
119
120/// Timecode drift detector and corrector.
121#[derive(Debug, Clone)]
122pub struct DriftDetector {
123    /// Configuration.
124    config: DriftConfig,
125    /// Collected samples.
126    samples: Vec<DriftSample>,
127}
128
129impl DriftDetector {
130    /// Creates a new drift detector with the given configuration.
131    pub fn new(config: DriftConfig) -> Self {
132        Self {
133            config,
134            samples: Vec::new(),
135        }
136    }
137
138    /// Adds a drift sample.
139    pub fn add_sample(&mut self, sample: DriftSample) {
140        self.samples.push(sample);
141    }
142
143    /// Returns the number of collected samples.
144    pub fn sample_count(&self) -> usize {
145        self.samples.len()
146    }
147
148    /// Clears all collected samples.
149    pub fn clear_samples(&mut self) {
150        self.samples.clear();
151    }
152
153    /// Returns the latest drift in frames, or None if no samples.
154    pub fn latest_drift(&self) -> Option<i64> {
155        self.samples.last().map(DriftSample::drift_frames)
156    }
157
158    /// Analyzes collected drift data and returns a summary.
159    #[allow(clippy::cast_precision_loss)]
160    pub fn analyze(&self) -> Option<DriftAnalysis> {
161        if self.samples.len() < self.config.min_samples {
162            return None;
163        }
164
165        let n = self.samples.len() as f64;
166
167        // Calculate drift rate via linear regression (drift vs wall time)
168        let sum_t: f64 = self.samples.iter().map(|s| s.wall_time_secs).sum();
169        let sum_d: f64 = self.samples.iter().map(|s| s.drift_frames() as f64).sum();
170        let sum_td: f64 = self
171            .samples
172            .iter()
173            .map(|s| s.wall_time_secs * s.drift_frames() as f64)
174            .sum();
175        let sum_t2: f64 = self
176            .samples
177            .iter()
178            .map(|s| s.wall_time_secs * s.wall_time_secs)
179            .sum();
180
181        let denom = n * sum_t2 - sum_t * sum_t;
182        let slope = if denom.abs() > 1e-12 {
183            (n * sum_td - sum_t * sum_d) / denom
184        } else {
185            0.0
186        };
187
188        // slope is frames drift per second
189        let drift_frames_per_hour = slope * 3600.0;
190        let fps = self.config.frame_rate.as_float();
191        let drift_ppm = if fps > 0.0 {
192            (slope / fps) * 1_000_000.0
193        } else {
194            0.0
195        };
196
197        let max_drift_frames = self
198            .samples
199            .iter()
200            .map(|s| s.drift_frames().unsigned_abs() as i64)
201            .max()
202            .unwrap_or(0);
203
204        let within_tolerance = max_drift_frames <= self.config.tolerance_frames as i64;
205
206        let recommended_strategy = if within_tolerance {
207            CorrectionStrategy::None
208        } else if drift_ppm.abs() > self.config.ppm_threshold {
209            CorrectionStrategy::RateAdjust
210        } else if max_drift_frames <= 5 {
211            CorrectionStrategy::PhaseShift
212        } else {
213            CorrectionStrategy::FrameDropRepeat
214        };
215
216        Some(DriftAnalysis {
217            drift_frames_per_hour,
218            drift_ppm,
219            max_drift_frames,
220            within_tolerance,
221            sample_count: self.samples.len(),
222            recommended_strategy,
223        })
224    }
225
226    /// Computes a corrected timecode by applying a frame offset to an observed timecode.
227    pub fn correct_timecode(
228        &self,
229        observed: &Timecode,
230        correction_frames: i64,
231    ) -> Result<Timecode, TimecodeError> {
232        let frame_rate = self.config.frame_rate;
233        let current = observed.to_frames();
234        let corrected = if correction_frames >= 0 {
235            current + correction_frames as u64
236        } else {
237            current.saturating_sub(correction_frames.unsigned_abs())
238        };
239        Timecode::from_frames(corrected, frame_rate)
240    }
241
242    /// Returns the frame rate from the configuration.
243    pub fn frame_rate(&self) -> FrameRate {
244        self.config.frame_rate
245    }
246}
247
248/// Computes effective PPM offset from two clock measurements.
249#[allow(clippy::cast_precision_loss)]
250pub fn compute_ppm(reference_frames: u64, observed_frames: u64) -> f64 {
251    if reference_frames == 0 {
252        return 0.0;
253    }
254    let diff = observed_frames as f64 - reference_frames as f64;
255    (diff / reference_frames as f64) * 1_000_000.0
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_drift_sample_creation() {
264        let s = DriftSample::new(1.0, 25, 25);
265        assert_eq!(s.wall_time_secs, 1.0);
266        assert_eq!(s.observed_frames, 25);
267        assert_eq!(s.expected_frames, 25);
268    }
269
270    #[test]
271    fn test_drift_sample_zero_drift() {
272        let s = DriftSample::new(1.0, 100, 100);
273        assert_eq!(s.drift_frames(), 0);
274        assert!((s.drift_ratio()).abs() < 1e-10);
275    }
276
277    #[test]
278    fn test_drift_sample_positive() {
279        let s = DriftSample::new(1.0, 102, 100);
280        assert_eq!(s.drift_frames(), 2);
281        assert!((s.drift_ratio() - 0.02).abs() < 1e-10);
282    }
283
284    #[test]
285    fn test_drift_sample_negative() {
286        let s = DriftSample::new(1.0, 98, 100);
287        assert_eq!(s.drift_frames(), -2);
288        assert!((s.drift_ratio() + 0.02).abs() < 1e-10);
289    }
290
291    #[test]
292    fn test_drift_sample_zero_expected() {
293        let s = DriftSample::new(0.0, 0, 0);
294        assert!((s.drift_ratio()).abs() < 1e-10);
295    }
296
297    #[test]
298    fn test_config_defaults() {
299        let c = DriftConfig::new(FrameRate::Fps25);
300        assert_eq!(c.tolerance_frames, 2);
301        assert_eq!(c.min_samples, 3);
302        assert!((c.ppm_threshold - 100.0).abs() < 1e-10);
303    }
304
305    #[test]
306    fn test_config_builder() {
307        let c = DriftConfig::new(FrameRate::Fps25)
308            .with_tolerance(5)
309            .with_min_samples(10)
310            .with_ppm_threshold(50.0);
311        assert_eq!(c.tolerance_frames, 5);
312        assert_eq!(c.min_samples, 10);
313        assert!((c.ppm_threshold - 50.0).abs() < 1e-10);
314    }
315
316    #[test]
317    fn test_detector_no_samples() {
318        let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
319        assert_eq!(det.sample_count(), 0);
320        assert!(det.latest_drift().is_none());
321        assert!(det.analyze().is_none());
322    }
323
324    #[test]
325    fn test_detector_add_sample() {
326        let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
327        det.add_sample(DriftSample::new(0.0, 0, 0));
328        det.add_sample(DriftSample::new(1.0, 25, 25));
329        assert_eq!(det.sample_count(), 2);
330        assert_eq!(det.latest_drift(), Some(0));
331    }
332
333    #[test]
334    fn test_analyze_no_drift() {
335        let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
336        det.add_sample(DriftSample::new(0.0, 0, 0));
337        det.add_sample(DriftSample::new(1.0, 25, 25));
338        det.add_sample(DriftSample::new(2.0, 50, 50));
339        let analysis = det.analyze().unwrap();
340        assert!(analysis.within_tolerance);
341        assert!((analysis.drift_ppm).abs() < 1.0);
342        assert_eq!(analysis.recommended_strategy, CorrectionStrategy::None);
343    }
344
345    #[test]
346    fn test_analyze_with_drift() {
347        let config = DriftConfig::new(FrameRate::Fps25).with_tolerance(1);
348        let mut det = DriftDetector::new(config);
349        det.add_sample(DriftSample::new(0.0, 0, 0));
350        det.add_sample(DriftSample::new(1.0, 26, 25));
351        det.add_sample(DriftSample::new(2.0, 52, 50));
352        let analysis = det.analyze().unwrap();
353        assert!(!analysis.within_tolerance);
354        assert!(analysis.max_drift_frames >= 1);
355    }
356
357    #[test]
358    fn test_correct_timecode_forward() {
359        let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
360        let tc = Timecode::new(0, 0, 1, 0, FrameRate::Fps25).unwrap();
361        let corrected = det.correct_timecode(&tc, 5).unwrap();
362        assert_eq!(corrected.seconds, 1);
363        assert_eq!(corrected.frames, 5);
364    }
365
366    #[test]
367    fn test_correct_timecode_backward() {
368        let det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
369        let tc = Timecode::new(0, 0, 1, 5, FrameRate::Fps25).unwrap();
370        let corrected = det.correct_timecode(&tc, -5).unwrap();
371        assert_eq!(corrected.seconds, 1);
372        assert_eq!(corrected.frames, 0);
373    }
374
375    #[test]
376    fn test_compute_ppm_zero() {
377        assert!((compute_ppm(1000, 1000)).abs() < 1e-10);
378    }
379
380    #[test]
381    fn test_compute_ppm_positive() {
382        // 1001 vs 1000 => 1000 PPM
383        let ppm = compute_ppm(1000, 1001);
384        assert!((ppm - 1000.0).abs() < 1e-6);
385    }
386
387    #[test]
388    fn test_compute_ppm_zero_reference() {
389        assert!((compute_ppm(0, 100)).abs() < 1e-10);
390    }
391
392    #[test]
393    fn test_clear_samples() {
394        let mut det = DriftDetector::new(DriftConfig::new(FrameRate::Fps25));
395        det.add_sample(DriftSample::new(0.0, 0, 0));
396        assert_eq!(det.sample_count(), 1);
397        det.clear_samples();
398        assert_eq!(det.sample_count(), 0);
399    }
400}