Skip to main content

ferraus_analysis/
levels.rs

1//! Level-based analyses: silence regions, clipping runs, and per-channel amplitude
2//! statistics with an optional dB histogram.
3
4use crate::util::{self, amp_db, pow_db};
5use ferraus_core::{db_to_lin, AudioBuffer};
6use serde::Serialize;
7
8// ---------------------------------------------------------------------------------
9// Silence
10// ---------------------------------------------------------------------------------
11
12/// One silent region.
13#[derive(Clone, Debug, Serialize)]
14pub struct SilenceRegion {
15    pub start_s: f64,
16    pub end_s: f64,
17    pub duration_s: f64,
18    /// RMS level of the region (loudest channel), dBFS.
19    pub floor_db: f64,
20}
21
22/// Result of [`detect_silence`].
23#[derive(Clone, Debug, Serialize)]
24pub struct SilenceReport {
25    pub threshold_db: f64,
26    pub min_duration_s: f64,
27    pub regions: Vec<SilenceRegion>,
28    pub total_silence_s: f64,
29    pub silent_fraction: f64,
30    /// Silence before the first frame above the threshold (regardless of `min`).
31    pub head_silence_s: f64,
32    /// Silence after the last frame above the threshold.
33    pub tail_silence_s: f64,
34}
35
36/// Frame RMS levels (dB, loudest channel) on a 10 ms grid; returns `(frame_len, levels, energies)`.
37fn frame_levels(buf: &AudioBuffer, frame_s: f64) -> (usize, Vec<f64>, Vec<f64>) {
38    let fl = ((buf.sample_rate as f64 * frame_s).round() as usize).max(1);
39    let n = buf.frames();
40    let count = n.div_ceil(fl);
41    let mut energies = vec![0.0f64; count];
42    for ch in &buf.data {
43        for (f, e) in energies.iter_mut().enumerate() {
44            let a = f * fl;
45            let b = (a + fl).min(n);
46            let ms = ch[a..b].iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / (b - a) as f64;
47            *e = e.max(ms);
48        }
49    }
50    let levels = energies.iter().map(|e| pow_db(*e)).collect();
51    (fl, levels, energies)
52}
53
54/// Find regions quieter than `threshold_db` (RMS over 10 ms frames, loudest channel)
55/// lasting at least `min_duration_s`.
56pub fn detect_silence(buf: &AudioBuffer, threshold_db: f64, min_duration_s: f64) -> SilenceReport {
57    let sr = buf.sample_rate as f64;
58    let n = buf.frames();
59    let dur = buf.duration_s();
60    let (fl, levels, energies) = frame_levels(buf, 0.01);
61    let mut regions = Vec::new();
62    let mut i = 0;
63    while i < levels.len() {
64        if levels[i] < threshold_db {
65            let s = i;
66            while i < levels.len() && levels[i] < threshold_db {
67                i += 1;
68            }
69            let start = (s * fl) as f64 / sr;
70            let end = ((i * fl).min(n)) as f64 / sr;
71            if end - start + 1e-9 >= min_duration_s {
72                let e = util::mean(&energies[s..i]);
73                regions.push(SilenceRegion {
74                    start_s: start,
75                    end_s: end,
76                    duration_s: end - start,
77                    floor_db: pow_db(e),
78                });
79            }
80        } else {
81            i += 1;
82        }
83    }
84    let first = levels.iter().position(|l| *l >= threshold_db);
85    let last = levels.iter().rposition(|l| *l >= threshold_db);
86    let (head, tail) = match (first, last) {
87        (Some(f), Some(l)) => ((f * fl) as f64 / sr, (dur - ((l + 1) * fl).min(n) as f64 / sr).max(0.0)),
88        _ => (dur, dur),
89    };
90    let total: f64 = regions.iter().map(|r| r.duration_s).sum();
91    SilenceReport {
92        threshold_db,
93        min_duration_s,
94        total_silence_s: total,
95        silent_fraction: if dur > 0.0 { total / dur } else { 0.0 },
96        regions,
97        head_silence_s: head,
98        tail_silence_s: tail,
99    }
100}
101
102// ---------------------------------------------------------------------------------
103// Clipping
104// ---------------------------------------------------------------------------------
105
106/// One run of clipped samples.
107#[derive(Clone, Debug, Serialize)]
108pub struct ClipRun {
109    pub channel: usize,
110    pub start_s: f64,
111    pub start_sample: usize,
112    pub length: usize,
113    /// Level of the run (dBFS).
114    pub level_db: f64,
115    /// `threshold` (at or above the threshold) or `flat` (a flat top below it).
116    pub kind: &'static str,
117}
118
119/// Result of [`detect_clipping`].
120#[derive(Clone, Debug, Serialize)]
121pub struct ClippingReport {
122    pub threshold_db: f64,
123    pub min_run: usize,
124    /// Number of clipped runs.
125    pub count: usize,
126    pub clipped_samples: usize,
127    pub clipped_fraction: f64,
128    pub runs: Vec<ClipRun>,
129    /// True when `runs` was cut to the first 1000 entries.
130    pub truncated: bool,
131}
132
133const MAX_RUNS: usize = 1000;
134
135/// Find runs of at least `min_run` consecutive samples at or above `threshold_db`
136/// (same polarity), plus flat-topped runs (identical samples at the channel's peak, above
137/// −12 dBFS) that indicate clipping below full scale.
138pub fn detect_clipping(buf: &AudioBuffer, threshold_db: f64, min_run: usize) -> ClippingReport {
139    let sr = buf.sample_rate as f64;
140    let thr = (db_to_lin(threshold_db) * (1.0 - 1e-4)) as f32;
141    let min_run = min_run.max(1);
142    let mut runs: Vec<ClipRun> = Vec::new();
143    let mut count = 0usize;
144    let mut clipped = 0usize;
145    for (c, ch) in buf.data.iter().enumerate() {
146        let peak = ch.iter().fold(0.0f32, |m, v| m.max(v.abs()));
147        let mut push = |start: usize, len: usize, level: f32, kind: &'static str| {
148            count += 1;
149            clipped += len;
150            if runs.len() < MAX_RUNS {
151                runs.push(ClipRun {
152                    channel: c,
153                    start_s: start as f64 / sr,
154                    start_sample: start,
155                    length: len,
156                    level_db: amp_db(level as f64),
157                    kind,
158                });
159            }
160        };
161        let mut i = 0;
162        while i < ch.len() {
163            let v = ch[i];
164            if v.abs() >= thr {
165                let s = i;
166                let sign = v.signum();
167                let mut lvl = 0.0f32;
168                while i < ch.len() && ch[i].abs() >= thr && ch[i].signum() == sign {
169                    lvl = lvl.max(ch[i].abs());
170                    i += 1;
171                }
172                if i - s >= min_run {
173                    push(s, i - s, lvl, "threshold");
174                }
175                continue;
176            }
177            // flat top below the threshold
178            if peak >= 0.25 && v.abs() >= 0.98 * peak && v.abs() < thr {
179                let s = i;
180                while i + 1 < ch.len() && (ch[i + 1] - v).abs() <= 1e-7 {
181                    i += 1;
182                }
183                let len = i - s + 1;
184                if len >= min_run.max(3) {
185                    push(s, len, v.abs(), "flat");
186                }
187                i += 1;
188                continue;
189            }
190            i += 1;
191        }
192    }
193    let total = buf.frames() * buf.channels();
194    ClippingReport {
195        threshold_db,
196        min_run,
197        count,
198        clipped_samples: clipped,
199        clipped_fraction: if total > 0 { clipped as f64 / total as f64 } else { 0.0 },
200        truncated: count > runs.len(),
201        runs,
202    }
203}
204
205// ---------------------------------------------------------------------------------
206// Amplitude statistics
207// ---------------------------------------------------------------------------------
208
209/// Sample-level histogram in dBFS: `counts[i]` holds samples whose level lies in
210/// `(bins_db[i] - 1, bins_db[i]]`; the first bin also holds everything quieter
211/// (including digital silence) and the last everything louder.
212#[derive(Clone, Debug, Serialize)]
213pub struct Histogram {
214    pub bins_db: Vec<f64>,
215    pub counts: Vec<u64>,
216}
217
218/// Statistics of one channel.
219#[derive(Clone, Debug, Serialize)]
220pub struct ChannelStats {
221    pub channel: usize,
222    pub peak_db: f64,
223    pub true_peak_dbtp: f64,
224    pub rms_db: f64,
225    /// Mean sample value (linear).
226    pub dc_offset: f64,
227    pub dc_offset_db: f64,
228    /// Peak minus RMS (dB).
229    pub crest_factor_db: f64,
230    /// Spread of 50 ms RMS windows: 95th minus 10th percentile of non-silent windows (dB).
231    pub dynamic_range_db: f64,
232    /// Quietest non-silent 50 ms RMS window (dBFS).
233    pub min_rms_db: f64,
234    /// Loudest 50 ms RMS window (dBFS).
235    pub max_rms_db: f64,
236    /// Samples at or above −0.001 dBFS.
237    pub full_scale_samples: usize,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub histogram: Option<Histogram>,
240}
241
242/// Statistics over all channels together.
243#[derive(Clone, Debug, Serialize)]
244pub struct OverallStats {
245    pub peak_db: f64,
246    pub true_peak_dbtp: f64,
247    pub rms_db: f64,
248    pub crest_factor_db: f64,
249    pub dynamic_range_db: f64,
250}
251
252/// Result of [`stats`].
253#[derive(Clone, Debug, Serialize)]
254pub struct StatsReport {
255    pub sample_rate: u32,
256    pub channels: usize,
257    pub frames: usize,
258    pub duration_s: f64,
259    pub overall: OverallStats,
260    pub per_channel: Vec<ChannelStats>,
261}
262
263fn window_rms_db(x: &[f32], win: usize) -> Vec<f64> {
264    x.chunks(win.max(1))
265        .filter(|c| c.len() * 2 >= win)
266        .map(|c| pow_db(c.iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / c.len() as f64))
267        .collect()
268}
269
270fn dynamic_range(levels: &[f64]) -> (f64, f64, f64) {
271    let active: Vec<f64> = levels.iter().copied().filter(|l| *l > -90.0).collect();
272    if active.is_empty() {
273        return (0.0, -240.0, -240.0);
274    }
275    let min = active.iter().copied().fold(f64::INFINITY, f64::min);
276    let max = active.iter().copied().fold(f64::NEG_INFINITY, f64::max);
277    (util::percentile(&active, 95.0) - util::percentile(&active, 10.0), min, max)
278}
279
280/// Build a 61-bin dB histogram (−60..0 dBFS in 1 dB steps).
281pub fn histogram(x: &[f32]) -> Histogram {
282    let mut counts = vec![0u64; 61];
283    for v in x {
284        let a = v.abs() as f64;
285        let idx = if a <= 1e-3 {
286            0
287        } else {
288            let db = 20.0 * a.log10();
289            ((db + 60.0).ceil().clamp(0.0, 60.0)) as usize
290        };
291        counts[idx] += 1;
292    }
293    Histogram { bins_db: (0..61).map(|i| -60.0 + i as f64).collect(), counts }
294}
295
296/// Per-channel and overall amplitude statistics.
297pub fn stats(buf: &AudioBuffer, with_histogram: bool) -> StatsReport {
298    let sr = buf.sample_rate;
299    let win = ((sr as f64 * 0.05).round() as usize).max(1);
300    let per_channel: Vec<ChannelStats> = buf
301        .data
302        .iter()
303        .enumerate()
304        .map(|(c, ch)| {
305            let n = ch.len().max(1) as f64;
306            let peak = ch.iter().fold(0.0f32, |m, v| m.max(v.abs())) as f64;
307            let ms = ch.iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / n;
308            let dc = ch.iter().map(|v| *v as f64).sum::<f64>() / n;
309            let tp = crate::loudness::true_peak_channel(ch, sr);
310            let (dr, mn, mx) = dynamic_range(&window_rms_db(ch, win));
311            let rms_db = pow_db(ms);
312            ChannelStats {
313                channel: c,
314                peak_db: amp_db(peak),
315                true_peak_dbtp: amp_db(tp),
316                rms_db,
317                dc_offset: dc,
318                dc_offset_db: amp_db(dc.abs()),
319                crest_factor_db: if ms > 0.0 { amp_db(peak) - rms_db } else { 0.0 },
320                dynamic_range_db: dr,
321                min_rms_db: mn,
322                max_rms_db: mx,
323                full_scale_samples: ch.iter().filter(|v| v.abs() >= 0.99988).count(),
324                histogram: if with_histogram { Some(histogram(ch)) } else { None },
325            }
326        })
327        .collect();
328    let peak = buf.peak() as f64;
329    let rms = buf.rms() as f64;
330    let mono = buf.mono_mix();
331    let (dr, _, _) = dynamic_range(&window_rms_db(&mono, win));
332    let overall = OverallStats {
333        peak_db: amp_db(peak),
334        true_peak_dbtp: per_channel.iter().map(|c| c.true_peak_dbtp).fold(-240.0, f64::max),
335        rms_db: amp_db(rms),
336        crest_factor_db: if rms > 0.0 { amp_db(peak) - amp_db(rms) } else { 0.0 },
337        dynamic_range_db: dr,
338    };
339    StatsReport {
340        sample_rate: sr,
341        channels: buf.channels(),
342        frames: buf.frames(),
343        duration_s: buf.duration_s(),
344        overall,
345        per_channel,
346    }
347}
348
349/// Crest factor of the whole buffer: peak minus RMS over all channels (dB).
350pub fn crest_factor_db(buf: &AudioBuffer) -> f64 {
351    let rms = buf.rms() as f64;
352    if rms <= 0.0 {
353        return 0.0;
354    }
355    amp_db(buf.peak() as f64) - amp_db(rms)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use ferraus_core::testing::{noise, sine, stereo};
362
363    #[test]
364    fn silence_regions_and_edges() {
365        let sr = 48000;
366        let mut b = AudioBuffer::new(sr, 1, sr as usize); // 1 s silence
367        b.append(&sine(sr, 440.0, 1.0, 0.5));
368        b.append(&AudioBuffer::new(sr, 1, (sr / 5) as usize)); // 200 ms gap
369        b.append(&sine(sr, 440.0, 1.0, 0.5));
370        b.append(&AudioBuffer::new(sr, 1, sr as usize / 2));
371        let r = detect_silence(&b, -40.0, 0.3);
372        assert_eq!(r.regions.len(), 2, "{:?}", r.regions);
373        assert!((r.regions[0].start_s - 0.0).abs() < 0.011);
374        assert!((r.regions[0].end_s - 1.0).abs() < 0.011);
375        assert!((r.head_silence_s - 1.0).abs() < 0.011);
376        assert!((r.tail_silence_s - 0.5).abs() < 0.011);
377        assert_eq!(r.regions[0].floor_db, -240.0);
378        let r = detect_silence(&b, -40.0, 0.1);
379        assert_eq!(r.regions.len(), 3);
380    }
381
382    #[test]
383    fn clipping_runs() {
384        let mut b = sine(48000, 100.0, 0.1, 1.4);
385        for v in b.data[0].iter_mut() {
386            *v = v.clamp(-1.0, 1.0);
387        }
388        let r = detect_clipping(&b, 0.0, 3);
389        assert_eq!(r.count, 20, "{}", r.count); // 10 cycles x 2 polarities
390        assert!(r.runs.iter().all(|x| x.kind == "threshold"));
391        // flat-topped at 0.5 (clipped then gained down)
392        let mut b = sine(48000, 100.0, 0.1, 0.8);
393        for v in b.data[0].iter_mut() {
394            *v = v.clamp(-0.5, 0.5);
395        }
396        let r = detect_clipping(&b, 0.0, 3);
397        assert_eq!(r.count, 20);
398        assert!(r.runs.iter().all(|x| x.kind == "flat"));
399        let clean = sine(48000, 1000.0, 0.5, 1.0);
400        assert_eq!(detect_clipping(&clean, 0.0, 3).count, 0);
401    }
402
403    #[test]
404    fn stats_on_sine_and_noise() {
405        let b = stereo(sine(48000, 1000.0, 1.0, 0.5));
406        let r = stats(&b, true);
407        let c = &r.per_channel[0];
408        assert!((c.peak_db + 6.02).abs() < 0.01);
409        assert!((c.rms_db + 9.03).abs() < 0.01);
410        assert!((c.crest_factor_db - 3.01).abs() < 0.02);
411        assert!(c.dynamic_range_db < 0.1);
412        let h = c.histogram.as_ref().unwrap();
413        assert_eq!(h.counts.len(), 61);
414        assert_eq!(h.counts.iter().sum::<u64>(), 48000);
415        assert!(h.counts[54] > 0 && h.counts[55] == 0, "{:?}", &h.counts[50..]);
416        let mut d = noise(48000, 1.0, 0.1, 1);
417        d.data[0].iter_mut().for_each(|v| *v += 0.05);
418        let r = stats(&d, false);
419        assert!((r.per_channel[0].dc_offset - 0.05).abs() < 0.002);
420        assert!(r.per_channel[0].histogram.is_none());
421    }
422}