use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(dead_code)]
pub enum SyncMethod {
Clap,
Timecode,
Waveform,
Manual,
}
impl std::fmt::Display for SyncMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Clap => write!(f, "Clap"),
Self::Timecode => write!(f, "Timecode"),
Self::Waveform => write!(f, "Waveform"),
Self::Manual => write!(f, "Manual"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioVideoSync {
pub video_offset_ms: i64,
pub confidence: f64,
pub method: SyncMethod,
}
impl AudioVideoSync {
#[must_use]
pub fn new(video_offset_ms: i64, confidence: f64, method: SyncMethod) -> Self {
Self {
video_offset_ms,
confidence,
method,
}
}
#[must_use]
pub fn is_reliable(&self, threshold: f64) -> bool {
self.confidence >= threshold
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncReport {
pub video_duration_ms: u64,
pub audio_duration_ms: u64,
pub sync_offset_ms: i64,
pub drift_ppm: f64,
}
impl SyncReport {
#[must_use]
pub fn new(
video_duration_ms: u64,
audio_duration_ms: u64,
sync_offset_ms: i64,
drift_ppm: f64,
) -> Self {
Self {
video_duration_ms,
audio_duration_ms,
sync_offset_ms,
drift_ppm,
}
}
#[must_use]
pub fn is_in_sync(&self) -> bool {
self.drift_ppm.abs() < 1.0
}
#[must_use]
pub fn duration_delta_ms(&self) -> i64 {
self.audio_duration_ms as i64 - self.video_duration_ms as i64
}
}
#[must_use]
pub fn detect_clap(samples: &[f64], sample_rate: u32) -> Option<u64> {
if samples.is_empty() || sample_rate == 0 {
return None;
}
let sr = sample_rate as usize;
let window = (sr / 100).max(1);
let abs_samples: Vec<f64> = samples.iter().map(|&s| s.abs()).collect();
let smoothed: Vec<f64> = abs_samples
.windows(window)
.map(|w| w.iter().sum::<f64>() / w.len() as f64)
.collect();
let onset: Vec<f64> = smoothed
.windows(2)
.map(|w| (w[1] - w[0]).max(0.0))
.collect();
let (best_idx, best_val) = onset
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
let min_onset = 0.05 / window as f64;
if *best_val < min_onset {
return None;
}
let sample_idx = best_idx + window; let ms = (sample_idx as u64 * 1000) / u64::from(sample_rate);
Some(ms)
}
#[must_use]
pub fn cross_correlate_waveforms(a: &[f32], b: &[f32]) -> Vec<f32> {
if a.is_empty() || b.is_empty() {
return Vec::new();
}
let len = a.len() + b.len() - 1;
let mut result = vec![0.0_f32; len];
for (i, &ai) in a.iter().enumerate() {
for (j, &bj) in b.iter().enumerate() {
let lag_index = j as isize - i as isize + (b.len() as isize - 1);
if lag_index >= 0 && (lag_index as usize) < len {
result[lag_index as usize] += ai * bj;
}
}
}
result
}
#[must_use]
pub fn find_max_correlation_offset(a: &[f32], b: &[f32]) -> i32 {
if a.is_empty() || b.is_empty() {
return 0;
}
let corr = cross_correlate_waveforms(a, b);
let peak_idx = corr
.iter()
.enumerate()
.max_by(|(_, x), (_, y)| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal))
.map_or(0, |(i, _)| i);
let zero_lag = (b.len() as i32) - 1;
peak_idx as i32 - zero_lag
}
#[must_use]
pub fn compute_drift(start_offset_ms: i64, end_offset_ms: i64, duration_ms: u64) -> f64 {
if duration_ms == 0 {
return 0.0;
}
let delta_ms = (end_offset_ms - start_offset_ms) as f64;
(delta_ms / duration_ms as f64) * 1_000_000.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_method_display() {
assert_eq!(SyncMethod::Clap.to_string(), "Clap");
assert_eq!(SyncMethod::Timecode.to_string(), "Timecode");
assert_eq!(SyncMethod::Waveform.to_string(), "Waveform");
assert_eq!(SyncMethod::Manual.to_string(), "Manual");
}
#[test]
fn test_audio_video_sync_is_reliable_pass() {
let sync = AudioVideoSync::new(100, 0.9, SyncMethod::Clap);
assert!(sync.is_reliable(0.8));
}
#[test]
fn test_audio_video_sync_is_reliable_fail() {
let sync = AudioVideoSync::new(100, 0.5, SyncMethod::Waveform);
assert!(!sync.is_reliable(0.8));
}
#[test]
fn test_audio_video_sync_fields() {
let sync = AudioVideoSync::new(-250, 0.75, SyncMethod::Timecode);
assert_eq!(sync.video_offset_ms, -250);
assert_eq!(sync.method, SyncMethod::Timecode);
}
#[test]
fn test_sync_report_duration_delta() {
let r = SyncReport::new(60_000, 60_033, 0, 0.55);
assert_eq!(r.duration_delta_ms(), 33);
}
#[test]
fn test_sync_report_is_in_sync_true() {
let r = SyncReport::new(60_000, 60_000, 0, 0.1);
assert!(r.is_in_sync());
}
#[test]
fn test_sync_report_is_in_sync_false() {
let r = SyncReport::new(60_000, 60_000, 0, 5.0);
assert!(!r.is_in_sync());
}
#[test]
fn test_detect_clap_empty() {
assert!(detect_clap(&[], 48000).is_none());
}
#[test]
fn test_detect_clap_zero_sample_rate() {
let samples = vec![0.0_f64; 100];
assert!(detect_clap(&samples, 0).is_none());
}
#[test]
fn test_detect_clap_silent_signal() {
let samples = vec![0.0_f64; 48000];
assert!(detect_clap(&samples, 48000).is_none());
}
#[test]
fn test_detect_clap_finds_transient() {
let mut samples = vec![0.01_f64; 48000 * 2];
for i in 0..500 {
samples[48000 + i] = 1.0;
}
let ts = detect_clap(&samples, 48000);
assert!(ts.is_some());
let ms = ts.unwrap();
assert!(ms > 800 && ms < 1300, "timestamp={ms}");
}
#[test]
fn test_cross_correlate_empty() {
assert!(cross_correlate_waveforms(&[], &[1.0]).is_empty());
}
#[test]
fn test_cross_correlate_output_length() {
let a = vec![1.0_f32; 5];
let b = vec![1.0_f32; 3];
let corr = cross_correlate_waveforms(&a, &b);
assert_eq!(corr.len(), 7); }
#[test]
fn test_cross_correlate_identical_unit_impulse() {
let a = vec![0.0_f32, 1.0, 0.0];
let b = vec![0.0_f32, 1.0, 0.0];
let corr = cross_correlate_waveforms(&a, &b);
let peak = corr
.iter()
.enumerate()
.max_by(|(_, x), (_, y)| x.partial_cmp(y).unwrap())
.unwrap();
assert_eq!(peak.0, 2);
}
#[test]
fn test_find_max_correlation_offset_zero_lag() {
let a = vec![0.0_f32, 0.0, 1.0, 0.0, 0.0];
let b = vec![0.0_f32, 0.0, 1.0, 0.0, 0.0];
let lag = find_max_correlation_offset(&a, &b);
assert_eq!(lag, 0);
}
#[test]
fn test_find_max_correlation_offset_shifted() {
let a = vec![0.0_f32, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let b = vec![0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
let lag = find_max_correlation_offset(&a, &b);
assert_eq!(lag, 2);
}
#[test]
fn test_find_max_correlation_offset_empty() {
assert_eq!(find_max_correlation_offset(&[], &[]), 0);
}
#[test]
fn test_compute_drift_zero_duration() {
assert_eq!(compute_drift(0, 100, 0), 0.0);
}
#[test]
fn test_compute_drift_no_drift() {
assert_eq!(compute_drift(50, 50, 60_000), 0.0);
}
#[test]
fn test_compute_drift_known_value() {
let ppm = compute_drift(0, 100, 100_000);
assert!((ppm - 1000.0).abs() < 1e-6, "ppm={ppm}");
}
#[test]
fn test_compute_drift_negative() {
let ppm = compute_drift(100, 0, 100_000);
assert!((ppm + 1000.0).abs() < 1e-6, "ppm={ppm}");
}
}