//! Batch forge DSP: silence detection for trim-silence. //! //! Batch normalize (peak/LUFS) reuses the existing [`crate::edit::normalize`] //! operations applied across a selection; the new DSP here is content-boundary //! detection used to auto-trim leading and trailing silence. /// Find the half-open frame range `[start, end)` containing audible content — /// the first and last frame whose peak across channels exceeds `threshold_db` /// (dBFS). Returns `None` when the whole buffer is below the threshold (silent). pub fn find_content_bounds( samples: &[f32], channels: u16, threshold_db: f64, ) -> Option<(usize, usize)> { let ch = channels.max(1) as usize; let total_frames = samples.len() / ch; if total_frames == 0 { return None; } let threshold = 10.0_f64.powf(threshold_db / 20.0) as f32; let frame_peak = |frame: usize| -> f32 { let base = frame * ch; let mut peak = 0.0f32; for c in 0..ch { peak = peak.max(samples[base + c].abs()); } peak }; let start = (0..total_frames).find(|&f| frame_peak(f) > threshold)?; // `start` exists, so a last audible frame exists too. let last = (0..total_frames).rev().find(|&f| frame_peak(f) > threshold)?; Some((start, last + 1)) } /// Trim leading and trailing silence in place, keeping only the audible content /// region. Returns `true` if any frames were removed. A fully silent buffer is /// left untouched (returns `false`) rather than emptied. pub fn trim_silence(samples: &mut Vec, channels: u16, threshold_db: f64) -> bool { let ch = channels.max(1) as usize; let total_frames = samples.len() / ch; let Some((start, end)) = find_content_bounds(samples, channels, threshold_db) else { return false; }; if start == 0 && end == total_frames { return false; } // Trailing first so the leading drain indices stay valid. samples.truncate(end * ch); samples.drain(0..start * ch); true } #[cfg(test)] mod tests { use super::*; #[test] fn finds_content_in_padded_signal() { // mono: 2 silent, 3 loud, 2 silent let samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0]; let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap(); assert_eq!((start, end), (2, 5)); } #[test] fn silent_buffer_has_no_bounds() { let samples = vec![0.0f32; 100]; assert!(find_content_bounds(&samples, 1, -60.0).is_none()); } #[test] fn threshold_excludes_low_noise() { // A -80 dB noise floor (~0.0001) below a -60 dB threshold (~0.001). let samples = vec![0.0001, 0.0001, 0.5, 0.0001]; let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap(); assert_eq!((start, end), (2, 3)); } #[test] fn trim_silence_removes_pads() { let mut samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0]; let trimmed = trim_silence(&mut samples, 1, -60.0); assert!(trimmed); assert_eq!(samples, vec![0.8, -0.7, 0.6]); } #[test] fn trim_silence_stereo() { // 4 stereo frames: silent, loud, loud, silent. let mut samples = vec![0.0, 0.0, 0.5, 0.5, 0.6, -0.6, 0.0, 0.0]; let trimmed = trim_silence(&mut samples, 2, -60.0); assert!(trimmed); assert_eq!(samples, vec![0.5, 0.5, 0.6, -0.6]); } #[test] fn trim_silence_noop_when_already_tight() { let mut samples = vec![0.8, -0.7, 0.6]; let original = samples.clone(); assert!(!trim_silence(&mut samples, 1, -60.0)); assert_eq!(samples, original); } #[test] fn trim_silence_leaves_silent_buffer_untouched() { let mut samples = vec![0.0f32; 50]; assert!(!trim_silence(&mut samples, 1, -60.0)); assert_eq!(samples.len(), 50); } }