use crate::loudness::{integrated_loudness, true_peak_dbtp};
const CLIP_AMPLITUDE: f32 = 0.99;
const CLIP_RUN_MIN: usize = 2;
const NOISE_FRAME: usize = 2048;
const NOISE_HOP: usize = 512;
const NOISE_QUIET_RATIO: f32 = 0.15;
const NOISE_FLOOR_HIGH_DB: f32 = -45.0;
const NOISE_GAP_MIN_DB: f32 = 12.0;
#[derive(Debug, Clone)]
pub struct Stats {
pub sample_rate: u32,
pub channels: usize,
pub duration_sec: f32,
pub samples: usize,
pub peak_dbfs: f32,
pub rms_dbfs: f32,
pub crest_factor_db: f32,
pub integrated_lufs: f32,
pub true_peak_dbtp: f32,
pub dc_offset: f32,
pub noise_floor_dbfs: f32,
pub snr_db: f32,
pub clipped_samples: usize,
pub clipped_runs: usize,
pub channel_peaks: Vec<f32>,
pub channel_rms: Vec<f32>,
pub phase_correlation: Option<f32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion {
pub issue: &'static str,
pub detail: String,
pub command: &'static str,
pub args: &'static str,
}
impl Suggestion {
pub fn invocation(&self, input: &str) -> String {
if self.args.is_empty() {
format!("cathar {} {input}", self.command)
} else {
format!("cathar {} {input} {}", self.command, self.args)
}
}
}
impl Stats {
pub fn suggestions(&self) -> Vec<Suggestion> {
let mut out = Vec::new();
if self.clipped_runs > 0 || self.peak_dbfs > 0.0 {
out.push(Suggestion {
issue: "clipping",
detail: format!(
"{} run(s), {} sample(s), peak {:.1} dBFS — reducing gain will not restore flattened peaks",
self.clipped_runs, self.clipped_samples, self.peak_dbfs
),
command: "declip",
args: "",
});
} else if self.true_peak_dbtp > 0.0 {
out.push(Suggestion {
issue: "true-peak",
detail: format!(
"true peak {:.1} dBTP (intersample overs; sample peak {:.1} dBFS)",
self.true_peak_dbtp, self.peak_dbfs
),
command: "normalize",
args: "--true-peak -1",
});
}
let gap = self.peak_dbfs - self.noise_floor_dbfs;
if self.samples >= NOISE_FRAME * 2
&& self.noise_floor_dbfs > NOISE_FLOOR_HIGH_DB
&& gap >= NOISE_GAP_MIN_DB
{
out.push(Suggestion {
issue: "noise",
detail: format!(
"floor {:.1} dBFS, SNR {:.1} dB — denoise under the signal; `gate` only mutes the gaps",
self.noise_floor_dbfs, self.snr_db
),
command: "denoise",
args: "",
});
}
out
}
}
pub fn compute_stats(channels: &[Vec<f32>], sample_rate: u32) -> Option<Stats> {
if channels.is_empty() || channels[0].is_empty() {
return None;
}
let n = channels[0].len();
let samples = n;
let duration_sec = n as f32 / sample_rate as f32;
let mut peak_sq = 0.0f32;
let mut rms_acc = 0.0f64;
let mut dc_sum = 0.0f64;
let total = (n * channels.len()) as f64;
let mut channel_peaks = Vec::with_capacity(channels.len());
let mut channel_rms = Vec::with_capacity(channels.len());
for ch in channels {
let mut ch_peak = 0.0f32;
let mut ch_sum_sq = 0.0f64;
for &s in ch {
let abs = s.abs();
if abs > ch_peak {
ch_peak = abs;
}
ch_sum_sq += (s as f64) * (s as f64);
dc_sum += s as f64;
}
if ch_peak > peak_sq {
peak_sq = ch_peak;
}
rms_acc += ch_sum_sq;
channel_peaks.push(20.0 * ch_peak.max(1e-10).log10());
let ch_rms = (ch_sum_sq / n as f64).sqrt() as f32;
channel_rms.push(20.0 * ch_rms.max(1e-10).log10());
}
let rms = (rms_acc / total).sqrt() as f32;
let peak_dbfs = 20.0 * peak_sq.max(1e-10).log10();
let rms_dbfs = 20.0 * rms.max(1e-10).log10();
let crest_factor_db = peak_dbfs - rms_dbfs;
let dc_offset = (dc_sum / total) as f32;
let integrated_lufs = integrated_loudness(channels, sample_rate);
let true_peak_dbtp = true_peak_dbtp(channels, sample_rate);
let noise_floor_dbfs = noise_floor_dbfs(channels, rms_dbfs);
let snr_db = rms_dbfs - noise_floor_dbfs;
let (clipped_samples, clipped_runs) = clip_runs(channels);
let phase_correlation = if channels.len() >= 2 {
Some(crate::stereo::phase_correlation(&channels[0], &channels[1]))
} else {
None
};
Some(Stats {
sample_rate,
channels: channels.len(),
duration_sec,
samples,
peak_dbfs,
rms_dbfs,
crest_factor_db,
integrated_lufs,
true_peak_dbtp,
dc_offset,
noise_floor_dbfs,
snr_db,
clipped_samples,
clipped_runs,
channel_peaks,
channel_rms,
phase_correlation,
})
}
fn noise_floor_dbfs(channels: &[Vec<f32>], rms_dbfs: f32) -> f32 {
let n = channels[0].len();
if n < NOISE_FRAME {
return rms_dbfs;
}
let mut frame_rms = Vec::new();
let mut offset = 0;
while offset + NOISE_FRAME <= n {
let mut acc = 0.0f64;
let mut count = 0usize;
for ch in channels {
for &s in &ch[offset..offset + NOISE_FRAME] {
acc += (s as f64) * (s as f64);
count += 1;
}
}
frame_rms.push((acc / count as f64).sqrt() as f32);
offset += NOISE_HOP;
}
if frame_rms.is_empty() {
return rms_dbfs;
}
frame_rms.sort_by(|a, b| a.total_cmp(b));
let k = ((frame_rms.len() as f32) * NOISE_QUIET_RATIO).ceil() as usize;
let k = k.clamp(1, frame_rms.len());
let mean = frame_rms[..k].iter().sum::<f32>() / k as f32;
20.0 * mean.max(1e-10).log10()
}
fn clip_runs(channels: &[Vec<f32>]) -> (usize, usize) {
let mut samples = 0usize;
let mut runs = 0usize;
for ch in channels {
let mut i = 0;
while i < ch.len() {
if ch[i].abs() >= CLIP_AMPLITUDE {
let sign = ch[i].signum();
let start = i;
i += 1;
while i < ch.len() && ch[i].abs() >= CLIP_AMPLITUDE && ch[i].signum() == sign {
i += 1;
}
let len = i - start;
samples += len;
if len >= CLIP_RUN_MIN {
runs += 1;
}
} else {
i += 1;
}
}
}
(samples, runs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate_wave;
#[test]
fn stats_returns_none_for_empty() {
assert!(compute_stats(&[], 44100).is_none());
assert!(compute_stats(&[vec![]], 44100).is_none());
}
#[test]
fn stats_sine_values() {
let audio = generate_wave(48_000, 1000.0, 1.0, 0.0);
let s = compute_stats(&audio.channels, audio.sample_rate).unwrap();
assert_eq!(s.sample_rate, 48_000);
assert_eq!(s.channels, 1);
assert!(s.duration_sec > 0.99 && s.duration_sec < 1.01);
assert!((s.peak_dbfs - (-6.02)).abs() < 0.1, "peak: {}", s.peak_dbfs);
assert!((s.rms_dbfs - (-9.03)).abs() < 0.2, "rms: {}", s.rms_dbfs);
}
#[test]
fn stats_mono_peak() {
let audio = generate_wave(44_100, 440.0, 2.0, 0.0);
let s = compute_stats(&audio.channels, audio.sample_rate).unwrap();
assert!((s.channel_peaks[0] - (-6.02)).abs() < 0.1);
}
#[test]
fn clean_sine_has_no_clip_runs_or_suggestions() {
let audio = generate_wave(48_000, 1000.0, 1.0, 0.0);
let s = compute_stats(&audio.channels, audio.sample_rate).unwrap();
assert_eq!(s.clipped_samples, 0);
assert_eq!(s.clipped_runs, 0);
assert!(
(s.noise_floor_dbfs - s.rms_dbfs).abs() < 1.0,
"floor {} vs rms {}",
s.noise_floor_dbfs,
s.rms_dbfs
);
assert!(s.suggestions().is_empty(), "{:?}", s.suggestions());
}
#[test]
fn hard_clipped_sine_flags_declip() {
let mut audio = generate_wave(48_000, 1000.0, 1.0, 0.0);
for s in &mut audio.channels[0] {
*s = (*s * 4.0).clamp(-1.0, 1.0);
}
let s = compute_stats(&audio.channels, audio.sample_rate).unwrap();
assert!(s.clipped_runs > 0, "runs {}", s.clipped_runs);
assert!(s.clipped_samples > 100, "samples {}", s.clipped_samples);
let hints = s.suggestions();
assert!(hints.iter().any(|h| h.command == "declip"), "{hints:?}");
assert_eq!(hints[0].invocation("mix.wav"), "cathar declip mix.wav");
}
#[test]
fn noisy_gaps_flag_denoise() {
let sr = 48_000u32;
let n = sr as usize;
let mut sig = vec![0.0f32; n];
let mut rng: u64 = 7;
for s in &mut sig {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
*s = ((rng as f32) / (u64::MAX as f32) - 0.5) * 0.08;
}
for s in sig.iter_mut().skip(n / 3).take(n / 8) {
*s = 0.5;
}
let s = compute_stats(&[sig], sr).unwrap();
assert!(s.noise_floor_dbfs > -45.0, "floor {}", s.noise_floor_dbfs);
assert!(
s.peak_dbfs - s.noise_floor_dbfs >= 12.0,
"gap {}",
s.peak_dbfs - s.noise_floor_dbfs
);
let hints = s.suggestions();
assert!(
hints.iter().any(|h| h.command == "denoise"),
"floor {} peak {} hints {hints:?}",
s.noise_floor_dbfs,
s.peak_dbfs
);
}
#[test]
fn isolated_full_scale_sample_is_not_a_run() {
let mut audio = generate_wave(48_000, 440.0, 1.0, 0.0);
audio.channels[0][1000] = 1.0;
let s = compute_stats(&audio.channels, audio.sample_rate).unwrap();
assert_eq!(s.clipped_samples, 1);
assert_eq!(s.clipped_runs, 0);
assert!(!s.suggestions().iter().any(|h| h.issue == "clipping"), "{:?}", s.suggestions());
}
}