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
}
#[derive(Debug, Clone)]
pub struct SpectralAlignConfig {
pub fft_size: usize,
pub max_lag: Option<usize>,
}
impl Default for SpectralAlignConfig {
fn default() -> Self {
Self {
fft_size: 8192,
max_lag: None,
}
}
}
#[derive(Debug, Clone)]
pub struct SpectralAlignResult {
pub offset_samples: i32,
pub peak_value: f64,
pub confidence: f64,
}
#[must_use]
pub fn spectral_align(a: &[f32], b: &[f32], config: &SpectralAlignConfig) -> SpectralAlignResult {
if a.is_empty() || b.is_empty() {
return SpectralAlignResult {
offset_samples: 0,
peak_value: 0.0,
confidence: 0.0,
};
}
let min_len = a.len().max(b.len()).max(config.fft_size);
let n = min_len.next_power_of_two();
let mut ra = vec![0.0_f64; n];
let mut ia = vec![0.0_f64; n];
for (i, &v) in a.iter().enumerate() {
ra[i] = f64::from(v);
}
let mut rb = vec![0.0_f64; n];
let mut ib = vec![0.0_f64; n];
for (i, &v) in b.iter().enumerate() {
rb[i] = f64::from(v);
}
fft_in_place(&mut ra, &mut ia, false);
fft_in_place(&mut rb, &mut ib, false);
let mut cr = vec![0.0_f64; n];
let mut ci = vec![0.0_f64; n];
let mut sum_mag = 0.0_f64;
for k in 0..n {
let xr = ra[k] * rb[k] + ia[k] * ib[k];
let xi = ia[k] * rb[k] - ra[k] * ib[k];
sum_mag += (xr * xr + xi * xi).sqrt();
}
let eps = (sum_mag / n as f64) * 0.01 + 1e-15;
for k in 0..n {
let xr = ra[k] * rb[k] + ia[k] * ib[k];
let xi = ia[k] * rb[k] - ra[k] * ib[k];
let mag = (xr * xr + xi * xi).sqrt();
let denom = mag + eps;
cr[k] = xr / denom;
ci[k] = xi / denom;
}
fft_in_place(&mut cr, &mut ci, true);
let max_lag = config.max_lag.unwrap_or(n / 2);
let max_lag = max_lag.min(n / 2);
let mut best_idx = 0usize;
let mut best_val = f64::NEG_INFINITY;
for i in 0..max_lag.min(n) {
if cr[i] > best_val {
best_val = cr[i];
best_idx = i;
}
}
let start = if max_lag < n { n - max_lag } else { 0 };
for i in start..n {
if cr[i] > best_val {
best_val = cr[i];
best_idx = i;
}
}
let offset = if best_idx <= n / 2 {
best_idx as i32
} else {
best_idx as i32 - n as i32
};
let rms = (cr.iter().map(|v| v * v).sum::<f64>() / n as f64).sqrt();
let confidence = if rms > 1e-15 {
(best_val / (rms * (n as f64).sqrt())).clamp(0.0, 1.0)
} else {
0.0
};
SpectralAlignResult {
offset_samples: -offset,
peak_value: best_val,
confidence,
}
}
fn fft_in_place(re: &mut [f64], im: &mut [f64], inverse: bool) {
let n = re.len();
debug_assert_eq!(n, im.len());
if n <= 1 {
return;
}
debug_assert!(n.is_power_of_two());
let mut j = 0usize;
for i in 0..n {
if i < j {
re.swap(i, j);
im.swap(i, j);
}
let mut m = n >> 1;
while m >= 1 && j >= m {
j -= m;
m >>= 1;
}
j += m;
}
let sign: f64 = if inverse { 1.0 } else { -1.0 };
let mut len = 2;
while len <= n {
let half = len / 2;
let angle = sign * std::f64::consts::PI * 2.0 / len as f64;
let wn_r = angle.cos();
let wn_i = angle.sin();
let mut start = 0;
while start < n {
let mut wr = 1.0_f64;
let mut wi = 0.0_f64;
for k in 0..half {
let even = start + k;
let odd = start + k + half;
let tr = wr * re[odd] - wi * im[odd];
let ti = wr * im[odd] + wi * re[odd];
re[odd] = re[even] - tr;
im[odd] = im[even] - ti;
re[even] += tr;
im[even] += ti;
let new_wr = wr * wn_r - wi * wn_i;
wi = wr * wn_i + wi * wn_r;
wr = new_wr;
}
start += len;
}
len <<= 1;
}
if inverse {
let inv_n = 1.0 / n as f64;
for v in re.iter_mut() {
*v *= inv_n;
}
for v in im.iter_mut() {
*v *= inv_n;
}
}
}
#[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.expect("ms should be valid");
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).expect("partial_cmp should succeed"))
.expect("test expectation failed");
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}");
}
#[test]
fn test_fft_roundtrip() {
let n = 16;
let mut re: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
let mut im = vec![0.0_f64; n];
let original = re.clone();
fft_in_place(&mut re, &mut im, false);
fft_in_place(&mut re, &mut im, true);
for (i, (&orig, &recovered)) in original.iter().zip(re.iter()).enumerate() {
assert!(
(orig - recovered).abs() < 1e-10,
"FFT roundtrip mismatch at {i}: {orig} vs {recovered}"
);
}
}
#[test]
fn test_fft_dc_component() {
let n = 8;
let mut re = vec![1.0_f64; n];
let mut im = vec![0.0_f64; n];
fft_in_place(&mut re, &mut im, false);
assert!((re[0] - n as f64).abs() < 1e-10);
for i in 1..n {
assert!(re[i].abs() < 1e-10, "bin {i} should be zero: {}", re[i]);
}
}
#[test]
fn test_spectral_align_empty() {
let config = SpectralAlignConfig::default();
let result = spectral_align(&[], &[1.0], &config);
assert_eq!(result.offset_samples, 0);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn test_spectral_align_identical_signals() {
let n = 256;
let signal: Vec<f32> = (0..n).map(|i| (i as f32 * 0.1).sin()).collect();
let config = SpectralAlignConfig {
fft_size: 512,
max_lag: Some(64),
};
let result = spectral_align(&signal, &signal, &config);
assert_eq!(
result.offset_samples, 0,
"identical signals should have zero offset"
);
assert!(result.peak_value > 0.0, "peak should be positive");
}
#[test]
fn test_spectral_align_known_shift() {
let n = 1024;
let shift = 10;
let signal: Vec<f32> = (0..n)
.map(|i| {
let t = i as f32;
(t * 0.05).sin()
+ 0.5 * (t * 0.13).sin()
+ 0.3 * (t * 0.21).cos()
+ 0.2 * (t * 0.37).sin()
})
.collect();
let mut a_sig = vec![0.0_f32; n];
let mut b_sig = vec![0.0_f32; n];
for i in 0..n {
a_sig[i] = signal[i];
}
for i in shift..n {
b_sig[i] = signal[i - shift];
}
let config = SpectralAlignConfig {
fft_size: 2048,
max_lag: Some(64),
};
let result = spectral_align(&a_sig, &b_sig, &config);
assert!(
(result.offset_samples - shift as i32).abs() <= 2,
"expected offset ~{shift}, got {}",
result.offset_samples
);
}
#[test]
fn test_spectral_align_negative_shift() {
let n = 2048;
let shift = 8;
let signal: Vec<f32> = (0..n)
.map(|i| {
let t = i as f32;
(t * 0.07).sin()
+ 0.5 * (t * 0.19).cos()
+ 0.3 * (t * 0.31).sin()
+ 0.2 * (t * 0.47).cos()
})
.collect();
let mut a_sig = vec![0.0_f32; n];
let mut b_sig = vec![0.0_f32; n];
for i in 0..n {
b_sig[i] = signal[i];
}
for i in shift..n {
a_sig[i] = signal[i - shift];
}
let config = SpectralAlignConfig {
fft_size: 4096,
max_lag: Some(64),
};
let result = spectral_align(&a_sig, &b_sig, &config);
assert!(
(result.offset_samples + shift as i32).abs() <= 2,
"expected offset ~-{shift}, got {}",
result.offset_samples
);
}
#[test]
fn test_spectral_align_config_default() {
let config = SpectralAlignConfig::default();
assert_eq!(config.fft_size, 8192);
assert!(config.max_lag.is_none());
}
}