Skip to main content

max / audiofiles

3.8 KB · 113 lines History Blame Raw
1 //! Batch forge DSP: silence detection for trim-silence.
2 //!
3 //! Batch normalize (peak/LUFS) reuses the existing [`crate::edit::normalize`]
4 //! operations applied across a selection; the new DSP here is content-boundary
5 //! detection used to auto-trim leading and trailing silence.
6
7 /// Find the half-open frame range `[start, end)` containing audible content —
8 /// the first and last frame whose peak across channels exceeds `threshold_db`
9 /// (dBFS). Returns `None` when the whole buffer is below the threshold (silent).
10 pub fn find_content_bounds(
11 samples: &[f32],
12 channels: u16,
13 threshold_db: f64,
14 ) -> Option<(usize, usize)> {
15 let ch = channels.max(1) as usize;
16 let total_frames = samples.len() / ch;
17 if total_frames == 0 {
18 return None;
19 }
20 let threshold = 10.0_f64.powf(threshold_db / 20.0) as f32;
21
22 let frame_peak = |frame: usize| -> f32 {
23 let base = frame * ch;
24 let mut peak = 0.0f32;
25 for c in 0..ch {
26 peak = peak.max(samples[base + c].abs());
27 }
28 peak
29 };
30
31 let start = (0..total_frames).find(|&f| frame_peak(f) > threshold)?;
32 // `start` exists, so a last audible frame exists too.
33 let last = (0..total_frames).rev().find(|&f| frame_peak(f) > threshold)?;
34 Some((start, last + 1))
35 }
36
37 /// Trim leading and trailing silence in place, keeping only the audible content
38 /// region. Returns `true` if any frames were removed. A fully silent buffer is
39 /// left untouched (returns `false`) rather than emptied.
40 pub fn trim_silence(samples: &mut Vec<f32>, channels: u16, threshold_db: f64) -> bool {
41 let ch = channels.max(1) as usize;
42 let total_frames = samples.len() / ch;
43 let Some((start, end)) = find_content_bounds(samples, channels, threshold_db) else {
44 return false;
45 };
46 if start == 0 && end == total_frames {
47 return false;
48 }
49 // Trailing first so the leading drain indices stay valid.
50 samples.truncate(end * ch);
51 samples.drain(0..start * ch);
52 true
53 }
54
55 #[cfg(test)]
56 mod tests {
57 use super::*;
58
59 #[test]
60 fn finds_content_in_padded_signal() {
61 // mono: 2 silent, 3 loud, 2 silent
62 let samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0];
63 let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap();
64 assert_eq!((start, end), (2, 5));
65 }
66
67 #[test]
68 fn silent_buffer_has_no_bounds() {
69 let samples = vec![0.0f32; 100];
70 assert!(find_content_bounds(&samples, 1, -60.0).is_none());
71 }
72
73 #[test]
74 fn threshold_excludes_low_noise() {
75 // A -80 dB noise floor (~0.0001) below a -60 dB threshold (~0.001).
76 let samples = vec![0.0001, 0.0001, 0.5, 0.0001];
77 let (start, end) = find_content_bounds(&samples, 1, -60.0).unwrap();
78 assert_eq!((start, end), (2, 3));
79 }
80
81 #[test]
82 fn trim_silence_removes_pads() {
83 let mut samples = vec![0.0, 0.0, 0.8, -0.7, 0.6, 0.0, 0.0];
84 let trimmed = trim_silence(&mut samples, 1, -60.0);
85 assert!(trimmed);
86 assert_eq!(samples, vec![0.8, -0.7, 0.6]);
87 }
88
89 #[test]
90 fn trim_silence_stereo() {
91 // 4 stereo frames: silent, loud, loud, silent.
92 let mut samples = vec![0.0, 0.0, 0.5, 0.5, 0.6, -0.6, 0.0, 0.0];
93 let trimmed = trim_silence(&mut samples, 2, -60.0);
94 assert!(trimmed);
95 assert_eq!(samples, vec![0.5, 0.5, 0.6, -0.6]);
96 }
97
98 #[test]
99 fn trim_silence_noop_when_already_tight() {
100 let mut samples = vec![0.8, -0.7, 0.6];
101 let original = samples.clone();
102 assert!(!trim_silence(&mut samples, 1, -60.0));
103 assert_eq!(samples, original);
104 }
105
106 #[test]
107 fn trim_silence_leaves_silent_buffer_untouched() {
108 let mut samples = vec![0.0f32; 50];
109 assert!(!trim_silence(&mut samples, 1, -60.0));
110 assert_eq!(samples.len(), 50);
111 }
112 }
113