1#![allow(dead_code)]
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum SyncMethod {
11 Ltc,
13 Clapper,
15 AudioCorrelate,
17 Timecode,
19}
20
21impl SyncMethod {
22 #[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 #[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#[derive(Debug, Clone)]
47pub struct SyncStream {
48 pub stream_id: String,
50 pub audio_samples: Vec<f32>,
52 pub timecode: Option<String>,
54 pub sample_rate: Option<u32>,
56}
57
58impl SyncStream {
59 #[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 #[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#[derive(Debug, Clone)]
84pub struct StreamSyncResult {
85 pub stream_id: String,
87 pub offset_ms: f64,
89 pub confidence: f64,
91 pub method: SyncMethod,
93}
94
95#[derive(Debug)]
97pub struct MulticamSyncResult {
98 pub streams: Vec<StreamSyncResult>,
100 pub method: SyncMethod,
102}
103
104impl MulticamSyncResult {
105 #[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 #[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 #[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#[derive(Debug)]
134pub struct MulticamSyncer {
135 streams: Vec<SyncStream>,
136 method: SyncMethod,
137}
138
139impl MulticamSyncer {
140 #[must_use]
142 pub fn new(method: SyncMethod) -> Self {
143 Self {
144 streams: Vec::new(),
145 method,
146 }
147 }
148
149 #[must_use]
151 pub fn stream_count(&self) -> usize {
152 self.streams.len()
153 }
154
155 pub fn add_stream(&mut self, stream: SyncStream) {
158 self.streams.push(stream);
159 }
160
161 #[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 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 #[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 #[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 #[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 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 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 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")); let result = syncer.sync_all().expect("result should be valid");
446 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}