Skip to main content

ferraus_analysis/
problems.rs

1//! Diagnostics (`analyze problems`): each detector returns a [`Problem`] with a severity,
2//! a plain-language explanation, and a suggested fix that is a runnable command or `fx`
3//! stage string.
4
5use crate::levels::{detect_clipping, detect_silence};
6use crate::loudness::{self, loudness_outliers};
7use crate::spectrum::{band_power_fraction, ltas};
8use crate::stereo;
9use crate::util::{self, amp_db, pow_db};
10use ferraus_core::AudioBuffer;
11use serde::Serialize;
12
13/// How much a problem matters.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17    Low,
18    Medium,
19    High,
20}
21
22/// One diagnosed problem.
23#[derive(Clone, Debug, Serialize)]
24pub struct Problem {
25    /// Machine-readable code: `clipping`, `dc_offset`, `clicks`, `hum`, `hiss`,
26    /// `noise_floor`, `phase`, `out_of_phase_bass`, `silence_head`, `silence_tail`,
27    /// `silent`, `loudness_outliers`, `inter_sample_peaks`, `rumble`, `sibilance`.
28    pub problem: String,
29    pub severity: Severity,
30    pub details: String,
31    /// A command (`silence trim --edges`) or fx stage (`highpass:freq=30Hz`); empty when
32    /// there is no automatic fix.
33    pub suggested_fix: String,
34}
35
36/// Count of problems per severity.
37#[derive(Clone, Debug, Default, Serialize)]
38pub struct SeverityCounts {
39    pub high: usize,
40    pub medium: usize,
41    pub low: usize,
42}
43
44/// Result of [`diagnose`].
45#[derive(Clone, Debug, Serialize)]
46pub struct ProblemsReport {
47    /// No medium or high severity problems.
48    pub clean: bool,
49    pub counts: SeverityCounts,
50    /// Sorted by severity, highest first.
51    pub problems: Vec<Problem>,
52}
53
54fn problem(code: &str, severity: Severity, details: String, fix: &str) -> Problem {
55    Problem { problem: code.into(), severity, details, suggested_fix: fix.into() }
56}
57
58fn fmt_times(t: &[f64]) -> String {
59    let shown: Vec<String> = t.iter().take(5).map(|v| format!("{v:.2}s")).collect();
60    let more = if t.len() > 5 { format!(" (+{} more)", t.len() - 5) } else { String::new() };
61    format!("{}{more}", shown.join(", "))
62}
63
64/// Mean value of each channel.
65pub fn dc_offsets(buf: &AudioBuffer) -> Vec<f64> {
66    buf.data.iter().map(|c| c.iter().map(|v| *v as f64).sum::<f64>() / c.len().max(1) as f64).collect()
67}
68
69/// Times (seconds) of isolated clicks: samples whose second difference exceeds ten times
70/// the local RMS of the second difference (±128 samples) and an absolute floor.
71pub fn detect_clicks(buf: &AudioBuffer) -> Vec<f64> {
72    let sr = buf.sample_rate as f64;
73    let merge = (sr * 0.002) as usize;
74    let mut times: Vec<usize> = Vec::new();
75    for ch in &buf.data {
76        let n = ch.len();
77        if n < 300 {
78            continue;
79        }
80        // second difference, computed on demand (no full-length buffers)
81        let e = |i: usize| -> f64 {
82            if i < 2 {
83                0.0
84            } else {
85                ch[i] as f64 - 2.0 * ch[i - 1] as f64 + ch[i - 2] as f64
86            }
87        };
88        // running energy of e over the window [i-128, i+128]
89        let mut sum: f64 = (0..129.min(n)).map(|j| e(j).powi(2)).sum();
90        let mut last: Option<usize> = None;
91        for i in 0..n {
92            if i > 0 {
93                if i + 128 < n {
94                    sum += e(i + 128).powi(2);
95                }
96                if i > 128 {
97                    sum -= e(i - 129).powi(2);
98                }
99            }
100            if i < 2 {
101                continue;
102            }
103            let a = i.saturating_sub(128);
104            let b = (i + 129).min(n);
105            let rms = (sum.max(0.0) / (b - a) as f64).sqrt();
106            let ei = e(i);
107            if ei.abs() > (10.0 * rms).max(0.01) {
108                if let Some(l) = last {
109                    if i - l <= merge {
110                        last = Some(i);
111                        continue;
112                    }
113                }
114                times.push(i);
115                last = Some(i);
116            }
117        }
118    }
119    times.sort_unstable();
120    times.dedup_by(|a, b| a.abs_diff(*b) <= merge);
121    times.into_iter().map(|i| i as f64 / sr).collect()
122}
123
124/// Mains hum found by [`detect_hum`].
125#[derive(Clone, Debug, Serialize)]
126pub struct HumInfo {
127    /// 50 or 60 Hz.
128    pub freq_hz: f64,
129    /// Harmonics (1 = fundamental) that stand out from their neighbourhood.
130    pub harmonics: Vec<usize>,
131    /// Hum power relative to the total signal power (dB).
132    pub relative_db: f64,
133    /// Hum level (dBFS, sine-referenced).
134    pub level_db: f64,
135}
136
137/// Look for narrow peaks at 50 or 60 Hz and their first 8 harmonics.
138pub fn detect_hum(buf: &AudioBuffer) -> Option<HumInfo> {
139    let sr = buf.sample_rate as f64;
140    let n_fft = util::next_pow2((sr / 1.5) as usize).min(util::next_pow2(buf.frames()) / 2).max(4096);
141    if buf.frames() < n_fft / 2 {
142        return None;
143    }
144    let l = ltas(buf, n_fft);
145    let df = l.bin_hz;
146    let total: f64 = l.power.iter().skip(1).sum();
147    if total <= 1e-20 {
148        return None;
149    }
150    let mut best: Option<HumInfo> = None;
151    for base in [50.0, 60.0] {
152        let mut harmonics = Vec::new();
153        let mut hum_power = 0.0;
154        for h in 1..=8usize {
155            let f = base * h as f64;
156            if f > sr / 2.0 - 20.0 {
157                break;
158            }
159            let k0 = ((f - 1.5) / df).floor().max(1.0) as usize;
160            let k1 = ((f + 1.5) / df).ceil() as usize;
161            let (kp, peak) = (k0..=k1).fold((k0, 0.0f64), |m, k| if l.power[k] > m.1 { (k, l.power[k]) } else { m });
162            let mut bg: Vec<f64> = Vec::new();
163            let lo = ((f - 15.0) / df).floor().max(1.0) as usize;
164            let hi = (((f + 15.0) / df).ceil() as usize).min(l.power.len() - 1);
165            for k in lo..=hi {
166                if (k as f64 * df - f).abs() > 4.0 {
167                    bg.push(l.power[k]);
168                }
169            }
170            let background = util::median(&bg).max(1e-30);
171            // power of the peak including the Hann main lobe
172            let lobe: f64 = (kp.saturating_sub(2)..=(kp + 2).min(l.power.len() - 1)).map(|k| l.power[k]).sum();
173            let prominence = pow_db(peak / background);
174            if prominence > 15.0 && pow_db(lobe) > -100.0 {
175                harmonics.push(h);
176                hum_power += lobe;
177            }
178        }
179        let strong_fundamental = harmonics.contains(&1) || harmonics.contains(&2);
180        if harmonics.len() >= 2 || (strong_fundamental && !harmonics.is_empty() && hum_power / total > 1e-4) {
181            let info = HumInfo {
182                freq_hz: base,
183                relative_db: pow_db(hum_power / total),
184                level_db: pow_db(hum_power) + 3.0103,
185                harmonics,
186            };
187            if best.as_ref().map(|b| info.relative_db > b.relative_db).unwrap_or(true) {
188                best = Some(info);
189            }
190        }
191    }
192    best
193}
194
195/// Quiet-passage noise floor estimates.
196#[derive(Clone, Debug, Serialize)]
197pub struct NoiseFloor {
198    /// High band (6–16 kHz) level in the quietest 10% of frames (dBFS).
199    pub hiss_db: f64,
200    /// Spectral flatness of that band in those frames (1 = noise-like).
201    pub hiss_flatness: f64,
202    /// Broadband level of the quietest 10% of frames (dBFS).
203    pub floor_db: f64,
204    /// Broadband spectral flatness of those frames.
205    pub floor_flatness: f64,
206    /// Level of the loudest 5% of frames (dBFS).
207    pub loud_db: f64,
208}
209
210/// Estimate hiss and broadband noise floor from the quietest frames.
211pub fn noise_floor(buf: &AudioBuffer) -> NoiseFloor {
212    let sr = buf.sample_rate as f64;
213    let x = buf.mono_mix();
214    let n_fft = 2048usize;
215    let frames = util::stft_mag(&x, n_fft, n_fft / 2);
216    let df = sr / n_fft as f64;
217    let wss = 3.0 * n_fft as f64 / 8.0;
218    let scale = 2.0 / (n_fft as f64 * wss);
219    let k6 = (6000.0 / df) as usize;
220    let k16 = ((16000.0f64.min(sr / 2.0 * 0.95)) / df) as usize;
221    let mut rows: Vec<(f64, f64, f64, f64)> = frames
222        .iter()
223        .skip(1)
224        .take(frames.len().saturating_sub(2))
225        .map(|f| {
226            let p: Vec<f64> = f.iter().map(|m| (*m as f64).powi(2) * scale).collect();
227            let total: f64 = p[1..].iter().sum();
228            let flat = |s: &[f64]| -> f64 {
229                if s.is_empty() {
230                    return 0.0;
231                }
232                let am = s.iter().sum::<f64>() / s.len() as f64;
233                if am <= 1e-30 {
234                    return 0.0;
235                }
236                let gm = (s.iter().map(|v| (v + 1e-30).ln()).sum::<f64>() / s.len() as f64).exp();
237                (gm / am).clamp(0.0, 1.0)
238            };
239            let (hi, hflat) =
240                if k16 > k6 + 4 { (p[k6..k16].iter().sum::<f64>(), flat(&p[k6..k16])) } else { (0.0, 0.0) };
241            (total, flat(&p[1..]), hi, hflat)
242        })
243        .collect();
244    if rows.is_empty() {
245        return NoiseFloor {
246            hiss_db: -240.0,
247            hiss_flatness: 0.0,
248            floor_db: -240.0,
249            floor_flatness: 0.0,
250            loud_db: -240.0,
251        };
252    }
253    rows.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
254    let q = (rows.len() / 10).max(1);
255    let quiet = &rows[..q];
256    let loud = &rows[rows.len() - (rows.len() / 20).max(1)..];
257    let med = |v: Vec<f64>| util::median(&v);
258    NoiseFloor {
259        hiss_db: pow_db(med(quiet.iter().map(|r| r.2).collect())),
260        hiss_flatness: med(quiet.iter().map(|r| r.3).collect()),
261        floor_db: pow_db(med(quiet.iter().map(|r| r.0).collect())),
262        floor_flatness: med(quiet.iter().map(|r| r.1).collect()),
263        loud_db: pow_db(med(loud.iter().map(|r| r.0).collect())),
264    }
265}
266
267/// Sibilance estimate: share of loud frames dominated by 5–10 kHz energy.
268#[derive(Clone, Debug, Serialize)]
269pub struct Sibilance {
270    /// Fraction of loud frames that are sibilant.
271    pub sibilant_fraction: f64,
272    /// Loudest 5–10 kHz level in a sibilant frame (dBFS).
273    pub peak_db: f64,
274}
275
276/// Measure sibilance (requires a sample rate of at least 22.05 kHz).
277pub fn sibilance(buf: &AudioBuffer) -> Sibilance {
278    let sr = buf.sample_rate as f64;
279    let none = Sibilance { sibilant_fraction: 0.0, peak_db: -240.0 };
280    if sr < 22050.0 {
281        return none;
282    }
283    let x = buf.mono_mix();
284    let n_fft = 1024usize;
285    let frames = util::stft_mag(&x, n_fft, n_fft / 2);
286    let df = sr / n_fft as f64;
287    let scale = 2.0 / (n_fft as f64 * (3.0 * n_fft as f64 / 8.0));
288    let (k5, k10) = ((5000.0 / df) as usize, ((10000.0f64.min(sr / 2.0)) / df) as usize);
289    let rows: Vec<(f64, f64)> = frames
290        .iter()
291        .map(|f| {
292            let p: Vec<f64> = f.iter().map(|m| (*m as f64).powi(2) * scale).collect();
293            (p[1..].iter().sum(), p[k5..k10].iter().sum())
294        })
295        .collect();
296    let max = rows.iter().map(|r| r.0).fold(0.0, f64::max);
297    if max <= 0.0 {
298        return none;
299    }
300    let loud: Vec<&(f64, f64)> = rows.iter().filter(|r| pow_db(r.0) > (pow_db(max) - 30.0).max(-45.0)).collect();
301    if loud.is_empty() {
302        return none;
303    }
304    let sib: Vec<&&(f64, f64)> = loud.iter().filter(|r| r.1 > 0.5 * r.0).collect();
305    Sibilance {
306        sibilant_fraction: sib.len() as f64 / loud.len() as f64,
307        peak_db: sib.iter().map(|r| pow_db(r.1) + 3.0103).fold(-240.0, f64::max),
308    }
309}
310
311/// Run every detector and collect the problems found.
312pub fn diagnose(buf: &AudioBuffer) -> ProblemsReport {
313    let mut out: Vec<Problem> = Vec::new();
314    let dur = buf.duration_s();
315    let sp = buf.peak() as f64;
316    if buf.is_empty() || sp == 0.0 {
317        out.push(problem("silent", Severity::High, "the file contains only digital silence".into(), ""));
318        return finish(out);
319    }
320
321    // clipping
322    let clip = detect_clipping(buf, 0.0, 3);
323    if clip.count > 0 {
324        let sev = if clip.clipped_fraction >= 1e-3 || clip.count > 50 {
325            Severity::High
326        } else if clip.count >= 5 {
327            Severity::Medium
328        } else {
329            Severity::Low
330        };
331        let times: Vec<f64> = clip.runs.iter().map(|r| r.start_s).collect();
332        out.push(problem(
333            "clipping",
334            sev,
335            format!(
336                "{} clipped run(s), {} samples ({:.3}%), at {}",
337                clip.count,
338                clip.clipped_samples,
339                clip.clipped_fraction * 100.0,
340                fmt_times(&times)
341            ),
342            "declip",
343        ));
344    }
345
346    // DC offset
347    let dcs = dc_offsets(buf);
348    let worst = dcs.iter().fold(0.0f64, |m, v| m.max(v.abs()));
349    if worst >= 0.001 {
350        let sev = if worst >= 0.02 {
351            Severity::High
352        } else if worst >= 0.005 {
353            Severity::Medium
354        } else {
355            Severity::Low
356        };
357        let per: Vec<String> = dcs.iter().enumerate().map(|(c, d)| format!("ch{} {:+.4}", c + 1, d)).collect();
358        out.push(problem(
359            "dc_offset",
360            sev,
361            format!("DC offset up to {:.4} ({:.1} dBFS): {}", worst, amp_db(worst), per.join(", ")),
362            "dc-remove",
363        ));
364    }
365
366    // inter-sample peaks
367    let tp = loudness::true_peak(buf);
368    let (tp_db, sp_db) = (amp_db(tp), amp_db(sp));
369    if tp_db > -1.0 && tp_db > sp_db + 0.3 {
370        let sev = if tp_db > 0.0 {
371            Severity::High
372        } else if tp_db > -0.5 {
373            Severity::Medium
374        } else {
375            Severity::Low
376        };
377        out.push(problem(
378            "inter_sample_peaks",
379            sev,
380            format!("true peak {tp_db:.2} dBTP is above the sample peak {sp_db:.2} dBFS"),
381            "limit:ceiling=-1dBTP",
382        ));
383    }
384
385    // clicks
386    let clicks = detect_clicks(buf);
387    if !clicks.is_empty() {
388        let rate = clicks.len() as f64 / (dur / 60.0).max(1e-9);
389        let sev = if clicks.len() >= 20 || (rate > 10.0 && clicks.len() >= 5) {
390            Severity::High
391        } else if clicks.len() >= 3 {
392            Severity::Medium
393        } else {
394            Severity::Low
395        };
396        out.push(problem("clicks", sev, format!("{} click(s) at {}", clicks.len(), fmt_times(&clicks)), "declick"));
397    }
398
399    // hum
400    if let Some(h) = detect_hum(buf) {
401        let sev = if h.relative_db > -20.0 {
402            Severity::High
403        } else if h.relative_db > -40.0 {
404            Severity::Medium
405        } else {
406            Severity::Low
407        };
408        out.push(problem(
409            "hum",
410            sev,
411            format!(
412                "{:.0} Hz hum (harmonics {:?}) at {:.1} dB relative to the signal",
413                h.freq_hz, h.harmonics, h.relative_db
414            ),
415            if h.freq_hz == 50.0 { "dehum:freq=50Hz" } else { "dehum:freq=60Hz" },
416        ));
417    }
418
419    // hiss and noise floor
420    let nf = noise_floor(buf);
421    if nf.hiss_db > -72.0 && nf.hiss_flatness > 0.3 {
422        let sev = if nf.hiss_db > -45.0 {
423            Severity::High
424        } else if nf.hiss_db > -58.0 {
425            Severity::Medium
426        } else {
427            Severity::Low
428        };
429        out.push(problem(
430            "hiss",
431            sev,
432            format!("high-frequency noise floor of {:.1} dBFS (6–16 kHz) in quiet passages", nf.hiss_db),
433            "dehiss",
434        ));
435    }
436    if nf.floor_db > -50.0 && nf.floor_flatness > 0.4 && nf.loud_db - nf.floor_db < 30.0 {
437        out.push(problem(
438            "noise_floor",
439            if nf.loud_db - nf.floor_db < 15.0 { Severity::High } else { Severity::Medium },
440            format!(
441                "broadband noise floor of {:.1} dBFS, only {:.1} dB below the loud passages",
442                nf.floor_db,
443                nf.loud_db - nf.floor_db
444            ),
445            "denoise:adaptive",
446        ));
447    }
448
449    // phase
450    if buf.channels() >= 2 {
451        let st = stereo::stereo_analysis(buf);
452        if st.correlation < -0.3 {
453            out.push(problem(
454                "phase",
455                Severity::High,
456                format!(
457                    "left and right are largely out of phase (correlation {:.2}); the mono sum cancels",
458                    st.correlation
459                ),
460                "channels mix --invert R",
461            ));
462        } else if st.correlation < 0.1 && st.width > 0.45 {
463            out.push(problem(
464                "phase",
465                Severity::Medium,
466                format!("weak L/R correlation ({:.2}); mono playback loses {:.1} dB", st.correlation, -st.mono_loss_db),
467                "phase-align",
468            ));
469        }
470        let l = ltas(buf, util::next_pow2((buf.sample_rate / 4) as usize).max(2048));
471        let low_share = band_power_fraction(&l, 20.0, 120.0);
472        let cl = st.correlation_below_120hz;
473        if low_share > 0.01 && cl < 0.8 {
474            let sev = if cl < 0.0 {
475                Severity::High
476            } else if cl < 0.5 {
477                Severity::Medium
478            } else {
479                Severity::Low
480            };
481            out.push(problem(
482                "out_of_phase_bass",
483                sev,
484                format!("L/R correlation below 120 Hz is {cl:.2}; bass will thin out or cancel in mono"),
485                "widen:mono-below=120Hz",
486            ));
487        }
488    }
489
490    // silence at the edges
491    let sil = detect_silence(buf, -60.0, 0.1);
492    if sil.head_silence_s > 0.5 {
493        out.push(problem(
494            "silence_head",
495            if sil.head_silence_s >= 2.0 { Severity::Medium } else { Severity::Low },
496            format!("{:.2} s of silence (below -60 dBFS) at the start", sil.head_silence_s),
497            "silence trim --edges",
498        ));
499    }
500    if sil.tail_silence_s > 1.0 {
501        out.push(problem(
502            "silence_tail",
503            if sil.tail_silence_s >= 3.0 { Severity::Medium } else { Severity::Low },
504            format!("{:.2} s of silence (below -60 dBFS) at the end", sil.tail_silence_s),
505            "silence trim --edges",
506        ));
507    }
508
509    // loudness outliers
510    if dur >= 20.0 {
511        let o = loudness_outliers(buf, 8.0);
512        if !o.is_empty() {
513            let worst = o.iter().fold(0.0f64, |m, s| m.max(s.delta_lu.abs()));
514            let desc: Vec<String> =
515                o.iter().take(5).map(|s| format!("{:.1}-{:.1}s {:+.1} LU", s.start_s, s.end_s, s.delta_lu)).collect();
516            out.push(problem(
517                "loudness_outliers",
518                if worst > 12.0 { Severity::Medium } else { Severity::Low },
519                format!("sections far from the integrated loudness: {}", desc.join(", ")),
520                "leveler",
521            ));
522        }
523    }
524
525    // sub-30 Hz rumble
526    let lr = ltas(buf, util::next_pow2((buf.sample_rate / 2) as usize).max(4096));
527    let frac = band_power_fraction(&lr, 8.0, 30.0);
528    let rel = pow_db(frac);
529    let rumble_level = pow_db(frac * lr.power.iter().skip(1).sum::<f64>());
530    if rel > -30.0 && rumble_level > -70.0 {
531        out.push(problem(
532            "rumble",
533            if rel > -10.0 {
534                Severity::High
535            } else if rel > -20.0 {
536                Severity::Medium
537            } else {
538                Severity::Low
539            },
540            format!("energy below 30 Hz is {rel:.1} dB relative to the whole signal"),
541            "highpass:freq=30Hz",
542        ));
543    }
544
545    // sibilance
546    let s = sibilance(buf);
547    if s.sibilant_fraction >= 0.02 && s.sibilant_fraction <= 0.3 && s.peak_db > -25.0 {
548        out.push(problem(
549            "sibilance",
550            if s.sibilant_fraction > 0.1 && s.peak_db > -15.0 { Severity::Medium } else { Severity::Low },
551            format!(
552                "{:.0}% of loud frames are dominated by 5-10 kHz energy (peak {:.1} dBFS)",
553                s.sibilant_fraction * 100.0,
554                s.peak_db
555            ),
556            "deess",
557        ));
558    }
559    finish(out)
560}
561
562fn finish(mut out: Vec<Problem>) -> ProblemsReport {
563    out.sort_by_key(|p| std::cmp::Reverse(p.severity));
564    let mut counts = SeverityCounts::default();
565    for p in &out {
566        match p.severity {
567            Severity::High => counts.high += 1,
568            Severity::Medium => counts.medium += 1,
569            Severity::Low => counts.low += 1,
570        }
571    }
572    ProblemsReport { clean: counts.high == 0 && counts.medium == 0, counts, problems: out }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use ferraus_core::testing::{add, noise, sine, stereo};
579
580    fn has(r: &ProblemsReport, code: &str) -> bool {
581        r.problems.iter().any(|p| p.problem == code)
582    }
583
584    #[test]
585    fn clean_sine_has_no_serious_problems() {
586        for f in [440.0, 1000.0] {
587            let b = stereo(sine(48000, f, 4.0, 0.5));
588            let r = diagnose(&b);
589            assert!(r.problems.iter().all(|p| p.severity != Severity::High), "{r:?}");
590            assert!(r.clean, "{r:?}");
591        }
592    }
593
594    #[test]
595    fn detects_clipping_and_dc() {
596        let mut b = sine(48000, 200.0, 2.0, 1.6);
597        b.data[0].iter_mut().for_each(|v| *v = v.clamp(-1.0, 1.0));
598        let r = diagnose(&b);
599        assert!(has(&r, "clipping"), "{r:?}");
600        assert_eq!(r.problems[0].suggested_fix, "declip");
601        let mut d = stereo(sine(48000, 440.0, 2.0, 0.4));
602        d.data.iter_mut().for_each(|c| c.iter_mut().for_each(|v| *v += 0.05));
603        let r = diagnose(&d);
604        let p = r.problems.iter().find(|p| p.problem == "dc_offset").expect("dc");
605        assert_eq!(p.severity, Severity::High);
606        assert_eq!(p.suggested_fix, "dc-remove");
607    }
608
609    #[test]
610    fn detects_hum() {
611        let mut hum = sine(48000, 60.0, 3.0, 0.03);
612        for h in 2..=4 {
613            hum = add(&hum, &sine(48000, 60.0 * h as f64, 3.0, 0.03 / h as f32));
614        }
615        let b = add(&sine(48000, 440.0, 3.0, 0.3), &hum);
616        let r = diagnose(&b);
617        let p = r.problems.iter().find(|p| p.problem == "hum").expect("hum");
618        assert_eq!(p.suggested_fix, "dehum:freq=60Hz");
619        // a 50 Hz fundamental alone
620        let b = add(&sine(48000, 1000.0, 3.0, 0.3), &sine(48000, 50.0, 3.0, 0.05));
621        let r = diagnose(&b);
622        assert!(r.problems.iter().any(|p| p.suggested_fix == "dehum:freq=50Hz"), "{r:?}");
623    }
624
625    #[test]
626    fn detects_head_silence() {
627        let mut b = AudioBuffer::new(48000, 2, 48000 * 3);
628        b.append(&stereo(sine(48000, 440.0, 2.0, 0.5)));
629        let r = diagnose(&b);
630        let p = r.problems.iter().find(|p| p.problem == "silence_head").expect("head");
631        assert_eq!(p.severity, Severity::Medium);
632        assert_eq!(p.suggested_fix, "silence trim --edges");
633    }
634
635    #[test]
636    fn detects_out_of_phase_bass() {
637        let bass = sine(48000, 60.0, 3.0, 0.4).data[0].clone();
638        let hi = sine(48000, 1500.0, 3.0, 0.3).data[0].clone();
639        let l: Vec<f32> = bass.iter().zip(&hi).map(|(b, h)| b + h).collect();
640        let r: Vec<f32> = bass.iter().zip(&hi).map(|(b, h)| -b + h).collect();
641        let rep = diagnose(&AudioBuffer::from_planar(48000, vec![l, r]));
642        let p = rep.problems.iter().find(|p| p.problem == "out_of_phase_bass").expect("bass");
643        assert_eq!(p.severity, Severity::High);
644        assert_eq!(p.suggested_fix, "widen:mono-below=120Hz");
645    }
646
647    #[test]
648    fn detects_clicks_hiss_rumble_and_silence() {
649        let mut b = sine(48000, 440.0, 3.0, 0.3);
650        for t in [0.5, 1.2, 2.1, 2.7] {
651            b.data[0][(t * 48000.0) as usize] += 0.6;
652        }
653        let r = diagnose(&b);
654        let p = r.problems.iter().find(|p| p.problem == "clicks").expect("clicks");
655        assert!(p.details.starts_with("4 click"), "{}", p.details);
656        let h = add(&sine(48000, 440.0, 3.0, 0.3), &noise(48000, 3.0, 0.02, 3));
657        assert!(has(&diagnose(&h), "hiss"));
658        let rb = add(&sine(48000, 440.0, 3.0, 0.2), &sine(48000, 18.0, 3.0, 0.3));
659        assert!(has(&diagnose(&rb), "rumble"));
660        let s = diagnose(&AudioBuffer::new(48000, 2, 4800));
661        assert!(has(&s, "silent"));
662        assert!(!s.clean);
663    }
664
665    #[test]
666    fn inter_sample_peaks() {
667        let x: Vec<f32> = (0..48000)
668            .map(|i| (0.99 * (std::f64::consts::PI / 2.0 * i as f64 + std::f64::consts::PI / 4.0).sin()) as f32)
669            .collect();
670        let r = diagnose(&AudioBuffer::from_mono(48000, x));
671        let p = r.problems.iter().find(|p| p.problem == "inter_sample_peaks").expect("isp");
672        assert_eq!(p.severity, Severity::Medium);
673    }
674}