simple_editor\engine/
autocut.rs

1//! Auto-cut: find "loud" segments (speech) vs "quiet" ones (ambient) in an audio clip from its waveform
2//! peaks (media::waveform::Peaks, 100 buckets/s) — no extra decoding. Pure functions, unit-tested.
3
4use crate::media::waveform::{Peaks, PEAKS_PER_SEC};
5
6const EPS: f64 = 1e-6;
7
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct AutoCutParams {
10    /// Level threshold in dBFS (e.g. -35): buckets above are "loud".
11    pub threshold_db: f32,
12    /// Quiet stretches shorter than this (seconds) are absorbed into the surrounding loud segment.
13    pub min_silence: f64,
14    /// Loud stretches shorter than this (seconds) are dropped (clicks/noise).
15    pub min_speech: f64,
16    /// Extra time kept before/after each loud segment (seconds).
17    pub padding: f64,
18}
19
20impl Default for AutoCutParams {
21    fn default() -> Self {
22        Self { threshold_db: -35.0, min_silence: 0.4, min_speech: 0.2, padding: 0.1 }
23    }
24}
25
26/// Loud segments as (start, end) in SOURCE seconds within [src_in, src_in + src_len) of the analysed
27/// stream, merged/filtered per `params` and clamped to that window. Peak level per bucket =
28/// max(|min|, |max|) → dBFS. Empty peaks → empty result.
29pub fn loud_segments(peaks: &Peaks, src_in: f64, src_len: f64, params: &AutoCutParams) -> Vec<(f64, f64)> {
30    let per = PEAKS_PER_SEC as f64;
31    let b0 = ((src_in * per).floor().max(0.0)) as usize;
32    let b1 = (((src_in + src_len) * per).ceil().max(0.0) as usize).min(peaks.len());
33    if peaks.is_empty() || src_len <= 0.0 || b0 >= b1 {
34        return Vec::new();
35    }
36    // compare linear peak levels against the linear threshold (no log per bucket)
37    let thr = 10f32.powf(params.threshold_db / 20.0);
38    let mut segs: Vec<(f64, f64)> = Vec::new();
39    let mut run: Option<f64> = None;
40    for i in b0..=b1 {
41        let loud = i < b1 && peaks.min[i].abs().max(peaks.max[i].abs()) > thr;
42        match (loud, run) {
43            (true, None) => run = Some(i as f64 / per),
44            (false, Some(start)) => {
45                let end = i as f64 / per;
46                // absorb a short quiet gap into the previous segment
47                match segs.last_mut() {
48                    Some(last) if start - last.1 < params.min_silence => last.1 = end,
49                    _ => segs.push((start, end)),
50                }
51                run = None;
52            }
53            _ => {}
54        }
55    }
56    let (lo, hi) = (src_in, src_in + src_len);
57    let mut out: Vec<(f64, f64)> = Vec::new();
58    for (a, b) in segs {
59        if b - a < params.min_speech {
60            continue;
61        }
62        let (a, b) = ((a - params.padding).max(lo), (b + params.padding).min(hi));
63        if b <= a {
64            continue;
65        }
66        match out.last_mut() {
67            Some(last) if a <= last.1 => last.1 = last.1.max(b), // padding made neighbours touch
68            _ => out.push((a, b)),
69        }
70    }
71    out
72}
73
74/// Turn source-time loud segments into timeline cut times + ranges to remove for a clip that starts at
75/// `start` with `speed` (forward only; reversed/frozen clips are not auto-cut):
76/// returns (cuts, quiet_ranges) in timeline seconds, quiet ranges = the complement of the loud segments
77/// inside the clip. `keep_quiet` swaps the roles (keep ambient, remove speech).
78pub fn to_timeline(
79    segments: &[(f64, f64)],
80    start: f64,
81    src_in: f64,
82    duration: f64,
83    speed: f64,
84    keep_quiet: bool,
85) -> (Vec<f64>, Vec<(f64, f64)>) {
86    let speed = if speed > 0.0 { speed } else { 1.0 };
87    let end = start + duration;
88    let mut loud: Vec<(f64, f64)> = Vec::new();
89    for &(a, b) in segments {
90        let ta = (start + (a - src_in) / speed).max(start);
91        let tb = (start + (b - src_in) / speed).min(end);
92        if tb <= ta {
93            continue;
94        }
95        match loud.last_mut() {
96            Some(last) if ta <= last.1 => last.1 = last.1.max(tb),
97            _ => loud.push((ta, tb)),
98        }
99    }
100    let mut quiet: Vec<(f64, f64)> = Vec::new();
101    let mut pos = start;
102    for &(a, b) in &loud {
103        if a > pos + EPS {
104            quiet.push((pos, a));
105        }
106        pos = b;
107    }
108    if end > pos + EPS {
109        quiet.push((pos, end));
110    }
111    let mut cuts: Vec<f64> =
112        loud.iter().flat_map(|&(a, b)| [a, b]).filter(|&t| t > start + EPS && t < end - EPS).collect();
113    cuts.sort_by(f64::total_cmp);
114    cuts.dedup_by(|a, b| (*a - *b).abs() < EPS);
115    (cuts, if keep_quiet { loud } else { quiet })
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    /// 10 s of peaks with the given loud (source-second) ranges at −6 dB, quiet elsewhere at −80 dB.
123    fn peaks(loud: &[(f64, f64)]) -> Peaks {
124        let n = 1000;
125        let mut min = vec![-0.0001f32; n];
126        let mut max = vec![0.0001f32; n];
127        for &(a, b) in loud {
128            for i in (a * 100.0) as usize..((b * 100.0) as usize).min(n) {
129                min[i] = -0.5;
130                max[i] = 0.5;
131            }
132        }
133        Peaks { min, max }
134    }
135
136    fn close(got: &[(f64, f64)], want: &[(f64, f64)]) -> bool {
137        got.len() == want.len()
138            && got.iter().zip(want).all(|(g, w)| (g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6)
139    }
140
141    #[test]
142    fn segments_merge_drop_pad_clamp() {
143        let p = peaks(&[(1.0, 3.0), (3.2, 4.0), (5.0, 5.1), (8.0, 9.0)]);
144        let params = AutoCutParams::default();
145        // gap 3.0–3.2 (< 0.4) merges; 5.0–5.1 (< 0.2) drops; ±0.1 padding
146        let segs = loud_segments(&p, 0.0, 10.0, &params);
147        assert!(close(&segs, &[(0.9, 4.1), (7.9, 9.1)]), "{segs:?}");
148        // window clamps both ends
149        let segs = loud_segments(&p, 0.95, 8.0, &params);
150        assert!(close(&segs, &[(0.95, 4.1), (7.9, 8.95)]), "{segs:?}");
151        // higher threshold → nothing is loud
152        let segs = loud_segments(&p, 0.0, 10.0, &AutoCutParams { threshold_db: -3.0, ..params });
153        assert!(segs.is_empty(), "{segs:?}");
154        // padding joins segments that touch
155        let segs = loud_segments(&p, 0.0, 10.0, &AutoCutParams { padding: 2.0, ..params });
156        assert!(close(&segs, &[(0.0, 10.0)]), "{segs:?}");
157        // empty peaks / empty window
158        assert!(loud_segments(&Peaks { min: vec![], max: vec![] }, 0.0, 10.0, &params).is_empty());
159        assert!(loud_segments(&p, 0.0, 0.0, &params).is_empty());
160        assert!(loud_segments(&p, 20.0, 5.0, &params).is_empty());
161    }
162
163    #[test]
164    fn timeline_mapping() {
165        // plain clip: start 10, src_in 0, dur 5, speed 1
166        let (cuts, quiet) = to_timeline(&[(1.0, 2.0), (3.0, 4.0)], 10.0, 0.0, 5.0, 1.0, false);
167        assert_eq!(cuts, vec![11.0, 12.0, 13.0, 14.0]);
168        assert!(close(&quiet, &[(10.0, 11.0), (12.0, 13.0), (14.0, 15.0)]), "{quiet:?}");
169        // keep_quiet swaps: remove the loud parts
170        let (cuts2, remove) = to_timeline(&[(1.0, 2.0), (3.0, 4.0)], 10.0, 0.0, 5.0, 1.0, true);
171        assert_eq!(cuts2, cuts);
172        assert!(close(&remove, &[(11.0, 12.0), (13.0, 14.0)]), "{remove:?}");
173        // speed 2, src_in 1: source [1,9) → timeline [0,4); segment reaching the clip edges makes no cut there
174        let (cuts, quiet) = to_timeline(&[(1.0, 3.0), (5.0, 9.0)], 0.0, 1.0, 4.0, 2.0, false);
175        assert_eq!(cuts, vec![1.0, 2.0]);
176        assert!(close(&quiet, &[(1.0, 2.0)]), "{quiet:?}");
177        // segment fully outside the clip is ignored
178        let (cuts, quiet) = to_timeline(&[(20.0, 30.0)], 0.0, 0.0, 5.0, 1.0, false);
179        assert!(cuts.is_empty());
180        assert!(close(&quiet, &[(0.0, 5.0)]), "{quiet:?}");
181        // loud everywhere → no quiet ranges
182        let (cuts, quiet) = to_timeline(&[(0.0, 5.0)], 0.0, 0.0, 5.0, 1.0, false);
183        assert!(cuts.is_empty() && quiet.is_empty());
184    }
185}