//! Loop detection via start/end cross-correlation and beat-alignment heuristics. use tracing::instrument; /// Detect whether an audio sample is likely a loop. /// /// Uses two heuristics: /// 1. Cross-correlation between the start and end of the sample /// 2. Beat alignment: if BPM is known, check if duration is a clean /// multiple of the beat length #[instrument(skip_all)] pub fn is_loop(samples: &[f32], sample_rate: u32, bpm: Option) -> bool { if samples.len() < 1024 { return false; } let cross_corr = start_end_correlation(samples); let beat_aligned = bpm.is_some_and(|b| is_beat_aligned(samples, sample_rate, b)); // High correlation at boundaries + beat alignment = strong loop signal if cross_corr > 0.8 && beat_aligned { return true; } // Very high correlation alone is suggestive if cross_corr > 0.9 { return true; } // Beat-aligned with moderate correlation if beat_aligned && cross_corr > 0.5 { return true; } false } /// Normalized cross-correlation (Pearson) between first and last `window_size` samples. /// /// Window size is capped at 512 samples (~12ms at 44.1kHz) or 1/4 of the signal, /// whichever is smaller. 512 is enough to capture a few cycles of bass frequencies /// while staying fast. fn start_end_correlation(samples: &[f32]) -> f64 { let window_size = 512.min(samples.len() / 4); if window_size < 64 { return 0.0; } let start = &samples[..window_size]; let end = &samples[samples.len() - window_size..]; // Pearson correlation: subtract means, then compute covariance / (std_s * std_e). let mean_s: f64 = start.iter().map(|&s| s as f64).sum::() / window_size as f64; let mean_e: f64 = end.iter().map(|&s| s as f64).sum::() / window_size as f64; // Accumulate covariance and variances in a single pass. let mut cov = 0.0f64; let mut var_s = 0.0f64; let mut var_e = 0.0f64; for i in 0..window_size { let ds = start[i] as f64 - mean_s; let de = end[i] as f64 - mean_e; cov += ds * de; // cross-covariance numerator var_s += ds * ds; // start variance numerator var_e += de * de; // end variance numerator } // denom = sqrt(var_s * var_e). If zero, one or both windows are constant (silent), // so there's no meaningful correlation — return 0. let denom = (var_s * var_e).sqrt(); if denom == 0.0 { 0.0 } else { (cov / denom).clamp(-1.0, 1.0) } } /// Check if the sample duration is a clean multiple of one beat at the given BPM. fn is_beat_aligned(samples: &[f32], sample_rate: u32, bpm: f64) -> bool { if bpm <= 0.0 || sample_rate == 0 { return false; } let duration = samples.len() as f64 / sample_rate as f64; let beat_length = 60.0 / bpm; let beats = duration / beat_length; let rounded = beats.round(); // Must be at least 1 beat long. if rounded < 1.0 { return false; } // Allow up to 3% relative error from a clean beat multiple. This tolerance accounts // for minor sample-count rounding and slight BPM estimation drift while still // rejecting arbitrary-length recordings. let error = (beats - rounded).abs() / rounded; error < 0.03 } #[cfg(test)] mod tests { use super::*; #[test] fn identical_start_end_is_loop() { // Create a signal that repeats (same at start and end) let period: Vec = (0..1024) .map(|i| (2.0 * std::f32::consts::PI * 2.0 * i as f32 / 1024.0).sin()) .collect(); let mut samples = Vec::new(); for _ in 0..8 { samples.extend_from_slice(&period); } assert!(start_end_correlation(&samples) > 0.9); } #[test] fn different_start_end_not_loop() { // Low-frequency sine at start, high-frequency noise at end let mut samples = vec![0.0f32; 8192]; for (i, s) in samples.iter_mut().enumerate() { if i < 4096 { // Low freq sine at start *s = (2.0 * std::f32::consts::PI * 100.0 * i as f32 / 44100.0).sin(); } else { // High freq sine at end (very different waveform shape) *s = (2.0 * std::f32::consts::PI * 8000.0 * i as f32 / 44100.0).sin() * 0.3; } } let corr = start_end_correlation(&samples); assert!(corr < 0.5, "different start/end correlation should be low, got {corr}"); } #[test] fn beat_alignment() { // 120 BPM = 0.5s per beat. 4 beats = 2.0s = 88200 samples at 44100 let samples = vec![0.0f32; 88200]; assert!(is_beat_aligned(&samples, 44100, 120.0)); // Not aligned: 2.13s at 120 BPM let samples = vec![0.0f32; 93933]; assert!(!is_beat_aligned(&samples, 44100, 120.0)); } #[test] fn short_sample_not_loop() { let short = vec![0.5f32; 100]; assert!(!is_loop(&short, 44100, None)); } }