use biquad::{Biquad, Coefficients, DirectForm2Transposed, Q_BUTTERWORTH_F64, Type};
use super::denoise::{DenoiseOptions, denoise, denoise_with_profile, noise_floor_dbfs};
use super::limiter::{LimiterOptions, LimiterReport, limit};
use super::loudness::{integrated_lufs, peak_dbfs};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy)]
pub struct EnhanceOptions {
pub highpass_hz: f64,
pub denoise: Option<DenoiseOptions>,
pub bass_boost_db: f64,
pub bass_corner_hz: f64,
pub presence_db: f64,
pub presence_corner_hz: f64,
pub target_lufs: f64,
pub peak_ceiling_dbfs: f64,
pub limiter_window_ms: Option<f64>,
pub max_limiting_db: f64,
}
impl Default for EnhanceOptions {
fn default() -> Self {
Self {
highpass_hz: 70.0,
denoise: Some(DenoiseOptions::default()),
bass_boost_db: 3.0,
bass_corner_hz: 140.0,
presence_db: 1.5,
presence_corner_hz: 7000.0,
target_lufs: -18.0,
peak_ceiling_dbfs: -1.0,
limiter_window_ms: Some(5.0),
max_limiting_db: 6.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EnhanceReport {
pub input_lufs: f64,
pub output_lufs: f64,
pub input_peak_dbfs: f64,
pub output_peak_dbfs: f64,
pub input_noise_floor_dbfs: f64,
pub output_noise_floor_dbfs: f64,
pub applied_gain_db: f64,
pub limiter_reduction_db: f64,
pub limiter_peak_reduction_db: f64,
pub reached_target: bool,
}
pub fn enhance(
samples: &[f32],
rate: u32,
opts: EnhanceOptions,
) -> Result<(Vec<f32>, EnhanceReport)> {
enhance_with_noise_profile(samples, None, rate, opts)
}
pub fn enhance_with_noise_profile(
samples: &[f32],
noise_clip: Option<&[f32]>,
rate: u32,
opts: EnhanceOptions,
) -> Result<(Vec<f32>, EnhanceReport)> {
if rate == 0 {
return Err(Error::InvalidArgument {
arg: "rate",
reason: "sample rate is 0".to_string(),
});
}
if samples.is_empty() {
return Err(Error::InvalidArgument {
arg: "samples",
reason: "no samples".to_string(),
});
}
let input_lufs = integrated_lufs(samples, rate)?;
let input_peak_dbfs = peak_dbfs(samples);
let input_noise_floor_dbfs = noise_floor_dbfs(samples, rate);
let rate_f = rate as f64;
let mut work = samples.to_vec();
let mut noise = noise_clip.map(|c| c.to_vec());
if opts.highpass_hz > 0.0 {
let hp = design(Type::HighPass, rate_f, opts.highpass_hz, "highpass_hz")?;
filter(hp, &mut work);
if let Some(n) = noise.as_mut() {
filter(hp, n);
}
}
if let Some(d) = opts.denoise {
work = match noise.as_deref() {
Some(n) => denoise_with_profile(&work, n, d)?,
None => denoise(&work, d)?,
};
}
if opts.bass_boost_db != 0.0 {
let shelf = design(
Type::LowShelf(opts.bass_boost_db),
rate_f,
opts.bass_corner_hz,
"bass_corner_hz",
)?;
filter(shelf, &mut work);
}
if opts.presence_db != 0.0 && opts.presence_corner_hz < rate_f / 2.0 {
let shelf = design(
Type::HighShelf(opts.presence_db),
rate_f,
opts.presence_corner_hz,
"presence_corner_hz",
)?;
filter(shelf, &mut work);
}
let before_gain = integrated_lufs(&work, rate)?;
let (levelled, limiting) = reach_target(&work, rate, before_gain, &opts)?;
work = levelled;
let output_lufs = integrated_lufs(&work, rate)?;
let applied_gain_db = if before_gain.is_finite() && output_lufs.is_finite() {
output_lufs - before_gain
} else {
f64::NEG_INFINITY
};
let report = EnhanceReport {
input_lufs,
output_lufs,
input_peak_dbfs,
output_peak_dbfs: peak_dbfs(&work),
input_noise_floor_dbfs,
output_noise_floor_dbfs: noise_floor_dbfs(&work, rate),
applied_gain_db,
limiter_reduction_db: limiting.sustained_reduction_db,
limiter_peak_reduction_db: limiting.max_reduction_db,
reached_target: (output_lufs - opts.target_lufs).abs() < 0.5,
};
Ok((work, report))
}
fn design(
kind: Type<f64>,
rate: f64,
corner_hz: f64,
option: &'static str,
) -> Result<Coefficients<f64>> {
let nyquist = rate / 2.0;
Coefficients::from_normalized_params(kind, corner_hz / nyquist, Q_BUTTERWORTH_F64).map_err(
|err| Error::InvalidArgument {
arg: option,
reason: format!(
"corner {corner_hz} Hz is outside 0..{nyquist} Hz, the band of {rate} Hz \
audio ({err:?}); set it below {nyquist} Hz or disable the stage"
),
},
)
}
fn filter(coeffs: Coefficients<f64>, samples: &mut [f32]) {
let mut section = DirectForm2Transposed::<f64>::new(coeffs);
for s in samples.iter_mut() {
*s = section.run(*s as f64) as f32;
}
}
fn reach_target(
samples: &[f32],
rate: u32,
current_lufs: f64,
opts: &EnhanceOptions,
) -> Result<(Vec<f32>, LimiterReport)> {
let idle = LimiterReport {
max_reduction_db: 0.0,
sustained_reduction_db: 0.0,
fraction_reduced: 0.0,
};
if !current_lufs.is_finite() {
return Ok((samples.to_vec(), idle));
}
let Some(window_ms) = opts.limiter_window_ms else {
let gain = gain_capped_by_peak(samples, opts.target_lufs - current_lufs, opts);
return Ok((apply_gain(samples, gain), idle));
};
let lim = LimiterOptions {
ceiling_dbfs: opts.peak_ceiling_dbfs,
window_ms,
};
let mut out = samples.to_vec();
let mut total = idle;
for _ in 0..6 {
let lufs = integrated_lufs(&out, rate)?;
if !lufs.is_finite() || (lufs - opts.target_lufs).abs() < 0.1 {
break;
}
let wanted_db = opts.target_lufs - lufs;
let (stepped, report) = largest_step_within_cap(&out, rate, wanted_db, lim, opts);
if stepped == out {
break;
}
out = stepped;
total = LimiterReport {
max_reduction_db: total.max_reduction_db.max(report.max_reduction_db),
sustained_reduction_db: total
.sustained_reduction_db
.max(report.sustained_reduction_db),
fraction_reduced: total.fraction_reduced.max(report.fraction_reduced),
};
}
Ok((out, total))
}
fn largest_step_within_cap(
samples: &[f32],
rate: u32,
wanted_db: f64,
lim: LimiterOptions,
opts: &EnhanceOptions,
) -> (Vec<f32>, LimiterReport) {
let attempt = |db: f64| limit(&apply_gain(samples, 10f64.powf(db / 20.0)), rate, lim);
if wanted_db <= 0.0 {
return attempt(wanted_db);
}
let (full, full_report) = attempt(wanted_db);
if full_report.sustained_reduction_db <= opts.max_limiting_db {
return (full, full_report);
}
let (mut lo, mut hi) = (0.0f64, wanted_db);
let mut best = (
samples.to_vec(),
LimiterReport {
max_reduction_db: 0.0,
sustained_reduction_db: 0.0,
fraction_reduced: 0.0,
},
);
for _ in 0..10 {
let mid = 0.5 * (lo + hi);
let (out, r) = attempt(mid);
if r.sustained_reduction_db <= opts.max_limiting_db {
best = (out, r);
lo = mid;
} else {
hi = mid;
}
}
best
}
fn gain_capped_by_peak(samples: &[f32], wanted_db: f64, opts: &EnhanceOptions) -> f64 {
let mut gain = 10f64.powf(wanted_db / 20.0);
let peak = peak_dbfs(samples);
if peak.is_finite() {
let ceiling_gain = 10f64.powf((opts.peak_ceiling_dbfs - peak) / 20.0);
if ceiling_gain < gain {
gain = ceiling_gain;
}
}
gain
}
fn apply_gain(samples: &[f32], gain: f64) -> Vec<f32> {
samples.iter().map(|&s| (s as f64 * gain) as f32).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const RATE: f64 = 48_000.0;
fn magnitude_at(c: &Coefficients<f64>, rate: f64, freq: f64) -> f64 {
let w = std::f64::consts::TAU * freq / rate;
let (s1, c1) = w.sin_cos();
let (s2, c2) = (2.0 * w).sin_cos();
let num_re = c.b0 + c.b1 * c1 + c.b2 * c2;
let num_im = -(c.b1 * s1 + c.b2 * s2);
let den_re = 1.0 + c.a1 * c1 + c.a2 * c2;
let den_im = -(c.a1 * s1 + c.a2 * s2);
((num_re * num_re + num_im * num_im) / (den_re * den_re + den_im * den_im)).sqrt()
}
#[test]
fn a_highpass_cuts_below_its_corner_and_passes_above() {
let hp = design(Type::HighPass, RATE, 80.0, "highpass_hz").unwrap();
let at_corner = magnitude_at(&hp, RATE, 80.0);
assert!(
(at_corner - 0.7079).abs() < 0.01,
"corner gain {at_corner:.4} should be -3 dB"
);
assert!(
magnitude_at(&hp, RATE, 20.0) < 0.1,
"20 Hz must be well down"
);
assert!(
magnitude_at(&hp, RATE, 1000.0) > 0.99,
"passband must be flat"
);
}
#[test]
fn shelves_apply_their_gain_on_the_correct_side() {
let low = design(Type::LowShelf(6.0), RATE, 200.0, "bass_corner_hz").unwrap();
assert!(
(magnitude_at(&low, RATE, 20.0) - 2.0).abs() < 0.05,
"low shelf lifts lows"
);
assert!(
(magnitude_at(&low, RATE, 8000.0) - 1.0).abs() < 0.05,
"and leaves highs"
);
let high = design(Type::HighShelf(6.0), RATE, 2000.0, "presence_corner_hz").unwrap();
assert!(
(magnitude_at(&high, RATE, 16000.0) - 2.0).abs() < 0.05,
"high shelf lifts highs"
);
assert!(
(magnitude_at(&high, RATE, 100.0) - 1.0).abs() < 0.05,
"and leaves lows"
);
}
#[test]
fn a_shelf_at_zero_gain_is_a_pass_through() {
let flat = design(Type::LowShelf(0.0), RATE, 200.0, "bass_corner_hz").unwrap();
for f in [20.0, 100.0, 1000.0, 10000.0] {
let g = magnitude_at(&flat, RATE, f);
assert!((g - 1.0).abs() < 1e-9, "{f} Hz gain {g}");
}
}
#[test]
fn filtering_a_tone_matches_the_computed_magnitude() {
let freq = 1000.0;
let hp = design(Type::HighPass, RATE, 300.0, "highpass_hz").unwrap();
let n = 48_000;
let mut buf: Vec<f32> = (0..n)
.map(|i| (std::f64::consts::TAU * freq * i as f64 / RATE).sin() as f32)
.collect();
filter(hp, &mut buf);
let tail = &buf[n / 2..];
let peak = tail.iter().fold(0.0f32, |a, &b| a.max(b.abs())) as f64;
let expected = magnitude_at(&hp, RATE, freq);
assert!(
(peak - expected).abs() < 0.02,
"measured {peak:.4} vs computed {expected:.4}"
);
}
#[test]
fn default_options_skip_a_presence_shelf_above_nyquist() {
let rate = 8_000u32;
let tone: Vec<f32> = (0..rate as usize * 2)
.map(|i| 0.3 * (std::f64::consts::TAU * 440.0 * i as f64 / rate as f64).sin() as f32)
.collect();
let (out, _) = enhance(&tone, rate, EnhanceOptions::default())
.unwrap_or_else(|e| panic!("default options on 8 kHz audio: {e}"));
assert_eq!(out.len(), tone.len());
}
#[test]
fn a_corner_at_or_above_nyquist_is_rejected() {
let err = design(Type::HighShelf(1.5), 8_000.0, 7_000.0, "presence_corner_hz")
.expect_err("7 kHz is above the 4 kHz Nyquist limit of 8 kHz audio");
let msg = err.to_string();
assert!(msg.contains("presence_corner_hz"), "{msg}");
assert!(msg.contains("4000"), "{msg}");
}
}