Skip to main content

oximedia_align/
multicam_sync.rs

1//! Multi-camera synchronisation using various sync methods.
2//!
3//! Supports LTC timecode, clapper detection, audio correlation, and embedded
4//! timecode to align multiple video/audio streams to a common timeline.
5
6#![allow(dead_code)]
7
8/// Method used to synchronise multi-camera streams.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum SyncMethod {
11    /// Linear Timecode embedded in the audio or metadata track.
12    Ltc,
13    /// Clapper slate detection (visual and/or audio spike).
14    Clapper,
15    /// Audio waveform cross-correlation.
16    AudioCorrelate,
17    /// Embedded timecode (SMPTE, VITC, etc.).
18    Timecode,
19}
20
21impl SyncMethod {
22    /// Returns the theoretical accuracy of this sync method in milliseconds.
23    #[must_use]
24    pub const fn accuracy_ms(&self) -> f64 {
25        match self {
26            Self::Ltc => 0.5,
27            Self::Clapper => 2.0,
28            Self::AudioCorrelate => 1.0,
29            Self::Timecode => 0.1,
30        }
31    }
32
33    /// Returns a human-readable name for this method.
34    #[must_use]
35    pub const fn name(&self) -> &'static str {
36        match self {
37            Self::Ltc => "LTC Timecode",
38            Self::Clapper => "Clapper/Slate",
39            Self::AudioCorrelate => "Audio Correlation",
40            Self::Timecode => "Embedded Timecode",
41        }
42    }
43}
44
45/// A single input stream to be synchronised.
46#[derive(Debug, Clone)]
47pub struct SyncStream {
48    /// Unique identifier for this stream.
49    pub stream_id: String,
50    /// Optional audio samples for correlation-based sync (normalised f32).
51    pub audio_samples: Vec<f32>,
52    /// Optional timecode string (e.g. `"01:00:00:00"`) for timecode-based sync.
53    pub timecode: Option<String>,
54    /// Optional sample rate when audio samples are provided.
55    pub sample_rate: Option<u32>,
56}
57
58impl SyncStream {
59    /// Creates a new stream with audio samples only.
60    #[must_use]
61    pub fn audio(stream_id: impl Into<String>, samples: Vec<f32>, sample_rate: u32) -> Self {
62        Self {
63            stream_id: stream_id.into(),
64            audio_samples: samples,
65            timecode: None,
66            sample_rate: Some(sample_rate),
67        }
68    }
69
70    /// Creates a new stream with a timecode string only.
71    #[must_use]
72    pub fn with_timecode(stream_id: impl Into<String>, timecode: impl Into<String>) -> Self {
73        Self {
74            stream_id: stream_id.into(),
75            audio_samples: Vec::new(),
76            timecode: Some(timecode.into()),
77            sample_rate: None,
78        }
79    }
80}
81
82/// Synchronisation result for a single stream relative to the reference stream.
83#[derive(Debug, Clone)]
84pub struct StreamSyncResult {
85    /// Identifier of the stream that was synced.
86    pub stream_id: String,
87    /// Offset to apply to this stream in milliseconds (positive = delay).
88    pub offset_ms: f64,
89    /// Confidence score for this sync result (0.0–1.0).
90    pub confidence: f64,
91    /// The method that produced this result.
92    pub method: SyncMethod,
93}
94
95/// Overall multi-camera sync result.
96#[derive(Debug)]
97pub struct MulticamSyncResult {
98    /// Per-stream synchronisation results (reference stream is index 0).
99    pub streams: Vec<StreamSyncResult>,
100    /// Method used to produce the sync.
101    pub method: SyncMethod,
102}
103
104impl MulticamSyncResult {
105    /// Returns the maximum absolute offset across all streams in milliseconds.
106    #[must_use]
107    pub fn max_offset_ms(&self) -> f64 {
108        self.streams
109            .iter()
110            .map(|s| s.offset_ms.abs())
111            .fold(0.0f64, f64::max)
112    }
113
114    /// Returns the average confidence across all stream results.
115    #[allow(clippy::cast_precision_loss)]
116    #[must_use]
117    pub fn average_confidence(&self) -> f64 {
118        if self.streams.is_empty() {
119            return 0.0;
120        }
121        let sum: f64 = self.streams.iter().map(|s| s.confidence).sum();
122        sum / self.streams.len() as f64
123    }
124
125    /// Returns the sync result for a stream by ID, if present.
126    #[must_use]
127    pub fn get_stream(&self, stream_id: &str) -> Option<&StreamSyncResult> {
128        self.streams.iter().find(|s| s.stream_id == stream_id)
129    }
130}
131
132/// Synchronises multiple camera streams to a shared timeline.
133#[derive(Debug)]
134pub struct MulticamSyncer {
135    streams: Vec<SyncStream>,
136    method: SyncMethod,
137}
138
139impl MulticamSyncer {
140    /// Creates a new syncer with the given sync method.
141    #[must_use]
142    pub fn new(method: SyncMethod) -> Self {
143        Self {
144            streams: Vec::new(),
145            method,
146        }
147    }
148
149    /// Returns the number of streams registered.
150    #[must_use]
151    pub fn stream_count(&self) -> usize {
152        self.streams.len()
153    }
154
155    /// Adds a stream to the syncer. The first stream added is used as the
156    /// reference (offset 0).
157    pub fn add_stream(&mut self, stream: SyncStream) {
158        self.streams.push(stream);
159    }
160
161    /// Synchronises all streams to the reference (first) stream.
162    ///
163    /// Returns `None` when fewer than two streams have been added.
164    #[must_use]
165    pub fn sync_all(&self) -> Option<MulticamSyncResult> {
166        if self.streams.len() < 2 {
167            return None;
168        }
169
170        let mut results = Vec::new();
171
172        // Reference stream always has offset 0.
173        let reference = &self.streams[0];
174        results.push(StreamSyncResult {
175            stream_id: reference.stream_id.clone(),
176            offset_ms: 0.0,
177            confidence: 1.0,
178            method: self.method,
179        });
180
181        for stream in self.streams.iter().skip(1) {
182            let (offset_ms, confidence) = match self.method {
183                SyncMethod::AudioCorrelate => self.audio_correlate_offset(reference, stream),
184                SyncMethod::Ltc | SyncMethod::Timecode => self.timecode_offset(reference, stream),
185                SyncMethod::Clapper => self.clapper_offset(reference, stream),
186            };
187            results.push(StreamSyncResult {
188                stream_id: stream.stream_id.clone(),
189                offset_ms,
190                confidence,
191                method: self.method,
192            });
193        }
194
195        Some(MulticamSyncResult {
196            streams: results,
197            method: self.method,
198        })
199    }
200
201    /// Computes an audio-correlation-based offset between two streams.
202    #[allow(clippy::cast_precision_loss)]
203    fn audio_correlate_offset(&self, reference: &SyncStream, other: &SyncStream) -> (f64, f64) {
204        let a = &reference.audio_samples;
205        let b = &other.audio_samples;
206        if a.is_empty() || b.is_empty() {
207            return (0.0, 0.0);
208        }
209        let sr = f64::from(reference.sample_rate.unwrap_or(48_000));
210        let max_shift = (a.len().min(b.len()) / 4).max(1);
211        let mut best_shift = 0i64;
212        let mut best_corr: f64 = -1.0;
213        let len = a.len().min(b.len());
214
215        for lag in 0..=max_shift as i64 {
216            for sign in [1i64, -1i64] {
217                let shift = lag * sign;
218                let corr = Self::xcorr(a, b, shift, len);
219                if corr > best_corr {
220                    best_corr = corr;
221                    best_shift = shift;
222                }
223            }
224        }
225        let offset_ms = (best_shift as f64 / sr) * 1000.0;
226        let confidence = best_corr.clamp(0.0, 1.0);
227        (offset_ms, confidence)
228    }
229
230    /// Simple normalised cross-correlation.
231    #[allow(clippy::cast_precision_loss)]
232    fn xcorr(a: &[f32], b: &[f32], lag: i64, len: usize) -> f64 {
233        let mut sum = 0.0f64;
234        let mut na = 0.0f64;
235        let mut nb = 0.0f64;
236        for i in 0..len {
237            let j = i as i64 + lag;
238            if j < 0 || j as usize >= b.len() {
239                continue;
240            }
241            let av = f64::from(a[i]);
242            let bv = f64::from(b[j as usize]);
243            sum += av * bv;
244            na += av * av;
245            nb += bv * bv;
246        }
247        let denom = (na * nb).sqrt();
248        if denom == 0.0 {
249            0.0
250        } else {
251            sum / denom
252        }
253    }
254
255    /// Parses a timecode string to milliseconds (HH:MM:SS:FF @ 25 fps).
256    #[allow(clippy::cast_precision_loss)]
257    fn parse_timecode_ms(tc: &str) -> Option<f64> {
258        let parts: Vec<&str> = tc.split(':').collect();
259        if parts.len() != 4 {
260            return None;
261        }
262        let h: f64 = parts[0].parse().ok()?;
263        let m: f64 = parts[1].parse().ok()?;
264        let s: f64 = parts[2].parse().ok()?;
265        let f: f64 = parts[3].parse().ok()?;
266        Some((h * 3600.0 + m * 60.0 + s + f / 25.0) * 1000.0)
267    }
268
269    fn timecode_offset(&self, reference: &SyncStream, other: &SyncStream) -> (f64, f64) {
270        let ref_ms = reference
271            .timecode
272            .as_deref()
273            .and_then(Self::parse_timecode_ms)
274            .unwrap_or(0.0);
275        let other_ms = other
276            .timecode
277            .as_deref()
278            .and_then(Self::parse_timecode_ms)
279            .unwrap_or(0.0);
280        (ref_ms - other_ms, 0.95)
281    }
282
283    fn clapper_offset(&self, _reference: &SyncStream, _other: &SyncStream) -> (f64, f64) {
284        // Simplified: use audio correlation as a stand-in.
285        self.audio_correlate_offset(_reference, _other)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_sync_method_accuracy_ordering() {
295        // Timecode should be most accurate (smallest value)
296        assert!(SyncMethod::Timecode.accuracy_ms() < SyncMethod::Ltc.accuracy_ms());
297        assert!(SyncMethod::Ltc.accuracy_ms() < SyncMethod::AudioCorrelate.accuracy_ms());
298        assert!(SyncMethod::AudioCorrelate.accuracy_ms() < SyncMethod::Clapper.accuracy_ms());
299    }
300
301    #[test]
302    fn test_sync_method_names_nonempty() {
303        let methods = [
304            SyncMethod::Ltc,
305            SyncMethod::Clapper,
306            SyncMethod::AudioCorrelate,
307            SyncMethod::Timecode,
308        ];
309        for m in methods {
310            assert!(!m.name().is_empty());
311        }
312    }
313
314    #[test]
315    fn test_sync_stream_audio_constructor() {
316        let s = SyncStream::audio("cam1", vec![0.0, 1.0], 48_000);
317        assert_eq!(s.stream_id, "cam1");
318        assert_eq!(s.sample_rate, Some(48_000));
319        assert!(s.timecode.is_none());
320    }
321
322    #[test]
323    fn test_sync_stream_timecode_constructor() {
324        let s = SyncStream::with_timecode("cam2", "01:00:00:00");
325        assert_eq!(s.stream_id, "cam2");
326        assert_eq!(s.timecode.as_deref(), Some("01:00:00:00"));
327        assert!(s.audio_samples.is_empty());
328    }
329
330    #[test]
331    fn test_syncer_add_stream_count() {
332        let mut syncer = MulticamSyncer::new(SyncMethod::AudioCorrelate);
333        syncer.add_stream(SyncStream::audio("a", vec![], 48_000));
334        syncer.add_stream(SyncStream::audio("b", vec![], 48_000));
335        assert_eq!(syncer.stream_count(), 2);
336    }
337
338    #[test]
339    fn test_syncer_sync_all_requires_two_streams() {
340        let mut syncer = MulticamSyncer::new(SyncMethod::AudioCorrelate);
341        syncer.add_stream(SyncStream::audio("a", vec![1.0], 48_000));
342        assert!(syncer.sync_all().is_none());
343    }
344
345    #[test]
346    fn test_syncer_sync_all_reference_offset_zero() {
347        let mut syncer = MulticamSyncer::new(SyncMethod::AudioCorrelate);
348        let sig: Vec<f32> = (0..4800).map(|i| (i as f32 * 0.01).sin()).collect();
349        syncer.add_stream(SyncStream::audio("ref", sig.clone(), 48_000));
350        syncer.add_stream(SyncStream::audio("b", sig, 48_000));
351        let result = syncer.sync_all().expect("result should be valid");
352        assert!((result.streams[0].offset_ms).abs() < f64::EPSILON);
353        assert_eq!(result.streams[0].stream_id, "ref");
354    }
355
356    #[test]
357    fn test_syncer_sync_all_identical_signals() {
358        let mut syncer = MulticamSyncer::new(SyncMethod::AudioCorrelate);
359        let sig: Vec<f32> = (0..4800).map(|i| (i as f32 * 0.01).sin()).collect();
360        syncer.add_stream(SyncStream::audio("ref", sig.clone(), 48_000));
361        syncer.add_stream(SyncStream::audio("b", sig, 48_000));
362        let result = syncer.sync_all().expect("result should be valid");
363        // Identical signals → offset should be 0
364        assert!((result.streams[1].offset_ms).abs() < 1.0);
365    }
366
367    #[test]
368    fn test_multicam_result_max_offset_ms() {
369        let results = MulticamSyncResult {
370            streams: vec![
371                StreamSyncResult {
372                    stream_id: "a".to_string(),
373                    offset_ms: 0.0,
374                    confidence: 1.0,
375                    method: SyncMethod::Timecode,
376                },
377                StreamSyncResult {
378                    stream_id: "b".to_string(),
379                    offset_ms: -15.0,
380                    confidence: 0.9,
381                    method: SyncMethod::Timecode,
382                },
383                StreamSyncResult {
384                    stream_id: "c".to_string(),
385                    offset_ms: 30.0,
386                    confidence: 0.85,
387                    method: SyncMethod::Timecode,
388                },
389            ],
390            method: SyncMethod::Timecode,
391        };
392        assert!((results.max_offset_ms() - 30.0).abs() < 1e-9);
393    }
394
395    #[test]
396    fn test_multicam_result_average_confidence() {
397        let results = MulticamSyncResult {
398            streams: vec![
399                StreamSyncResult {
400                    stream_id: "a".to_string(),
401                    offset_ms: 0.0,
402                    confidence: 1.0,
403                    method: SyncMethod::Clapper,
404                },
405                StreamSyncResult {
406                    stream_id: "b".to_string(),
407                    offset_ms: 5.0,
408                    confidence: 0.8,
409                    method: SyncMethod::Clapper,
410                },
411            ],
412            method: SyncMethod::Clapper,
413        };
414        assert!((results.average_confidence() - 0.9).abs() < 1e-9);
415    }
416
417    #[test]
418    fn test_multicam_result_get_stream_found() {
419        let results = MulticamSyncResult {
420            streams: vec![StreamSyncResult {
421                stream_id: "cam3".to_string(),
422                offset_ms: 10.0,
423                confidence: 0.95,
424                method: SyncMethod::Ltc,
425            }],
426            method: SyncMethod::Ltc,
427        };
428        assert!(results.get_stream("cam3").is_some());
429    }
430
431    #[test]
432    fn test_multicam_result_get_stream_not_found() {
433        let results = MulticamSyncResult {
434            streams: vec![],
435            method: SyncMethod::Ltc,
436        };
437        assert!(results.get_stream("missing").is_none());
438    }
439
440    #[test]
441    fn test_timecode_sync() {
442        let mut syncer = MulticamSyncer::new(SyncMethod::Timecode);
443        syncer.add_stream(SyncStream::with_timecode("ref", "01:00:00:00"));
444        syncer.add_stream(SyncStream::with_timecode("b", "01:00:01:00")); // 1 s later
445        let result = syncer.sync_all().expect("result should be valid");
446        // ref_ms - other_ms = 3_600_000 - 3_601_000 = -1000 ms
447        assert!((result.streams[1].offset_ms - (-1000.0)).abs() < 1.0);
448    }
449
450    #[test]
451    fn test_syncer_empty_audio_gives_zero_offset() {
452        let mut syncer = MulticamSyncer::new(SyncMethod::AudioCorrelate);
453        syncer.add_stream(SyncStream::audio("a", vec![], 48_000));
454        syncer.add_stream(SyncStream::audio("b", vec![], 48_000));
455        let result = syncer.sync_all().expect("result should be valid");
456        assert!((result.streams[1].offset_ms).abs() < f64::EPSILON);
457    }
458}