#![allow(clippy::many_single_char_names)]
#![allow(clippy::similar_names)]
use std::collections::VecDeque;
use std::f64::consts::PI;
const ABSOLUTE_GATE: f64 = -70.0;
const RELATIVE_GATE_OFFSET: f64 = -10.0;
const REF_SAMPLE_RATE: f64 = 48_000.0;
const K_STAGE1_B_48K: [f64; 3] = [
1.535_124_859_586_97,
-2.691_696_189_406_38,
1.198_392_810_852_85,
];
const K_STAGE1_A_48K: [f64; 2] = [-1.690_659_293_182_41, 0.732_480_774_215_85];
const K_STAGE2_B_48K: [f64; 3] = [1.0, -2.0, 1.0];
const K_STAGE2_A_48K: [f64; 2] = [-1.990_047_454_833_98, 0.990_072_250_366_21];
const TP_OVERSAMPLE: usize = 4;
const TP_FIR_HALF_LEN: usize = 12;
const TP_FIR_TAPS: usize = TP_FIR_HALF_LEN * 2;
#[derive(Clone, Debug)]
struct Biquad {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
w1: f64,
w2: f64,
}
impl Biquad {
fn new(b: [f64; 3], a: [f64; 2]) -> Self {
Self {
b0: b[0],
b1: b[1],
b2: b[2],
a1: a[0],
a2: a[1],
w1: 0.0,
w2: 0.0,
}
}
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b0 * x + self.w1;
self.w1 = self.b1 * x - self.a1 * y + self.w2;
self.w2 = self.b2 * x - self.a2 * y;
y
}
fn reset(&mut self) {
self.w1 = 0.0;
self.w2 = 0.0;
}
}
#[derive(Clone, Debug)]
pub struct KWeightingFilter {
stage1: Biquad,
stage2: Biquad,
}
impl KWeightingFilter {
pub fn new(sample_rate: u32) -> Self {
let fs = f64::from(sample_rate);
if (fs - REF_SAMPLE_RATE).abs() < 0.5 {
Self {
stage1: Biquad::new(K_STAGE1_B_48K, K_STAGE1_A_48K),
stage2: Biquad::new(K_STAGE2_B_48K, K_STAGE2_A_48K),
}
} else {
let (b1, a1) = Self::design_stage1(fs);
let (b2, a2) = Self::design_stage2(fs);
Self {
stage1: Biquad::new(b1, a1),
stage2: Biquad::new(b2, a2),
}
}
}
pub fn process(&mut self, sample: f64) -> f64 {
let s1 = self.stage1.process(sample);
self.stage2.process(s1)
}
pub fn process_block(&mut self, samples: &[f64]) -> Vec<f64> {
samples.iter().map(|&s| self.process(s)).collect()
}
pub fn reset(&mut self) {
self.stage1.reset();
self.stage2.reset();
}
fn design_stage1(fs: f64) -> ([f64; 3], [f64; 2]) {
const F0: f64 = 1_681.974_450_955_533;
const G_DB: f64 = 3.999_843_853_973_347;
const Q: f64 = 0.707_175_236_955_420;
let omega0 = 2.0 * PI * F0 / fs;
let k = (omega0 / 2.0).tan();
let vh = 10.0_f64.powf(G_DB / 20.0); let vb = vh.powf(0.5);
let denom = 1.0 + k / Q + k * k;
let b0 = (vh + vb * k / Q + k * k) / denom;
let b1 = 2.0 * (k * k - vh) / denom;
let b2 = (vh - vb * k / Q + k * k) / denom;
let a1 = 2.0 * (k * k - 1.0) / denom;
let a2 = (1.0 - k / Q + k * k) / denom;
([b0, b1, b2], [a1, a2])
}
fn design_stage2(fs: f64) -> ([f64; 3], [f64; 2]) {
const F0: f64 = 38.135_470_876_024_44;
const Q: f64 = 0.500_327_037_323_877;
let omega0 = 2.0 * PI * F0 / fs;
let k = (omega0 / 2.0).tan();
let denom = 1.0 + k / Q + k * k;
let b0 = 1.0 / denom;
let b1 = -2.0 / denom;
let b2 = 1.0 / denom;
let a1 = 2.0 * (k * k - 1.0) / denom;
let a2 = (1.0 - k / Q + k * k) / denom;
([b0, b1, b2], [a1, a2])
}
}
pub struct TruePeakDetector {
fir_phases: [[f64; TP_FIR_TAPS]; 3],
delay: [f64; TP_FIR_TAPS],
write_pos: usize,
max_peak: f64,
}
impl TruePeakDetector {
pub fn new(_sample_rate: u32) -> Self {
let fir_phases = Self::design_fir();
Self {
fir_phases,
delay: [0.0; TP_FIR_TAPS],
write_pos: 0,
max_peak: 0.0,
}
}
pub fn process_sample(&mut self, sample: f32) -> f64 {
let x = f64::from(sample);
self.delay[self.write_pos] = x;
self.write_pos = (self.write_pos + 1) % TP_FIR_TAPS;
let mut local_max = x.abs();
for phase_coeffs in &self.fir_phases {
let interpolated = self.convolve(phase_coeffs);
let abs_interp = interpolated.abs();
if abs_interp > local_max {
local_max = abs_interp;
}
}
if local_max > self.max_peak {
self.max_peak = local_max;
}
local_max
}
pub fn max_true_peak_dbtp(&self) -> f64 {
if self.max_peak > 0.0 {
20.0 * self.max_peak.log10()
} else {
f64::NEG_INFINITY
}
}
pub fn reset(&mut self) {
self.delay = [0.0; TP_FIR_TAPS];
self.write_pos = 0;
self.max_peak = 0.0;
}
#[inline]
fn convolve(&self, coeffs: &[f64; TP_FIR_TAPS]) -> f64 {
let mut sum = 0.0;
for (i, &c) in coeffs.iter().enumerate() {
let read_pos = (self.write_pos + i) % TP_FIR_TAPS;
sum += self.delay[read_pos] * c;
}
sum
}
fn design_fir() -> [[f64; TP_FIR_TAPS]; 3] {
let n_total = TP_FIR_TAPS as f64; let half = TP_FIR_HALF_LEN as f64;
let mut phases = [[0.0f64; TP_FIR_TAPS]; 3];
for (phase_idx, phase_coeffs) in phases.iter_mut().enumerate() {
let offset = (phase_idx + 1) as f64 / TP_OVERSAMPLE as f64;
let mut sum = 0.0;
for (tap, coeff) in phase_coeffs.iter_mut().enumerate() {
let t = tap as f64 - half + offset;
let window_idx = tap as f64 / (n_total - 1.0);
let hann = 0.5 * (1.0 - (2.0 * PI * window_idx).cos());
let sinc = if t.abs() < 1e-12 {
1.0
} else {
(PI * t).sin() / (PI * t)
};
*coeff = sinc * hann;
sum += *coeff;
}
if sum.abs() > 1e-12 {
for c in phase_coeffs.iter_mut() {
*c /= sum;
}
}
}
phases
}
}
pub struct EbuR128Meter {
sample_rate: u32,
channels: u32,
k_filters: Vec<KWeightingFilter>,
hop_accumulator: Vec<f64>,
hop_count: usize,
hop_size: usize,
momentary_buf: VecDeque<f64>,
short_term_buf: VecDeque<f64>,
momentary_sum: f64,
short_term_sum: f64,
momentary_cap_hops: usize,
short_term_cap_hops: usize,
momentary_lufs: f64,
momentary_max: f64,
short_term_lufs: f64,
short_term_max: f64,
gating_blocks: Vec<(f64, f64)>,
hop_powers: VecDeque<f64>,
tp_detector: TruePeakDetector,
channel_weights: Vec<f64>,
weight_sum: f64,
}
impl EbuR128Meter {
pub fn new(sample_rate: u32, channels: u32) -> Self {
let channels_usize = channels as usize;
let k_filters = (0..channels_usize)
.map(|_| KWeightingFilter::new(sample_rate))
.collect();
let hop_size = (u64::from(sample_rate) * 100 / 1000) as usize;
let momentary_cap_hops = 4; let short_term_cap_hops = 30;
let channel_weights = Self::channel_weights(channels_usize);
let weight_sum: f64 = channel_weights.iter().sum();
Self {
sample_rate,
channels,
k_filters,
hop_accumulator: vec![0.0; channels_usize],
hop_count: 0,
hop_size,
momentary_buf: VecDeque::new(),
short_term_buf: VecDeque::new(),
momentary_sum: 0.0,
short_term_sum: 0.0,
momentary_cap_hops,
short_term_cap_hops,
momentary_lufs: f64::NEG_INFINITY,
momentary_max: f64::NEG_INFINITY,
short_term_lufs: f64::NEG_INFINITY,
short_term_max: f64::NEG_INFINITY,
gating_blocks: Vec::new(),
hop_powers: VecDeque::new(),
tp_detector: TruePeakDetector::new(sample_rate),
channel_weights,
weight_sum,
}
}
pub fn process(&mut self, samples: &[f32]) {
let channels = self.channels as usize;
let frames = samples.len() / channels;
for frame in 0..frames {
let base = frame * channels;
let mut hop_ms = 0.0;
for ch in 0..channels {
let s = f64::from(samples[base + ch]);
let kw = self.k_filters[ch].process(s);
hop_ms += kw * kw * self.channel_weights[ch];
}
self.hop_accumulator[0] += hop_ms;
self.tp_detector.process_sample(samples[base]);
self.hop_count += 1;
if self.hop_count >= self.hop_size {
self.complete_hop();
}
}
}
fn complete_hop(&mut self) {
let n = self.hop_count;
if n == 0 {
return;
}
let raw_sum = self.hop_accumulator[0];
let hop_power = if self.weight_sum > 0.0 {
raw_sum / (n as f64 * self.weight_sum)
} else {
0.0
};
self.momentary_buf.push_back(hop_power);
self.momentary_sum += hop_power;
if self.momentary_buf.len() > self.momentary_cap_hops {
let removed = self.momentary_buf.pop_front().unwrap_or(0.0);
self.momentary_sum -= removed;
}
if self.momentary_buf.len() == self.momentary_cap_hops {
let mean = self.momentary_sum / self.momentary_cap_hops as f64;
self.momentary_lufs = Self::power_to_lufs(mean);
if self.momentary_lufs > self.momentary_max {
self.momentary_max = self.momentary_lufs;
}
}
self.short_term_buf.push_back(hop_power);
self.short_term_sum += hop_power;
if self.short_term_buf.len() > self.short_term_cap_hops {
let removed = self.short_term_buf.pop_front().unwrap_or(0.0);
self.short_term_sum -= removed;
}
if self.short_term_buf.len() == self.short_term_cap_hops {
let mean = self.short_term_sum / self.short_term_cap_hops as f64;
self.short_term_lufs = Self::power_to_lufs(mean);
if self.short_term_lufs > self.short_term_max {
self.short_term_max = self.short_term_lufs;
}
}
self.hop_powers.push_back(hop_power);
if self.hop_powers.len() > self.momentary_cap_hops {
self.hop_powers.pop_front();
}
if self.hop_powers.len() == self.momentary_cap_hops {
let block_mean: f64 =
self.hop_powers.iter().sum::<f64>() / self.momentary_cap_hops as f64;
let block_lufs = Self::power_to_lufs(block_mean);
self.gating_blocks.push((block_lufs, block_mean));
}
for v in &mut self.hop_accumulator {
*v = 0.0;
}
self.hop_count = 0;
}
pub fn momentary_lufs(&self) -> f64 {
self.momentary_lufs
}
pub fn short_term_lufs(&self) -> f64 {
self.short_term_lufs
}
pub fn integrated_lufs(&self) -> f64 {
let abs_gated: Vec<(f64, f64)> = self
.gating_blocks
.iter()
.copied()
.filter(|&(lufs, _)| lufs >= ABSOLUTE_GATE)
.collect();
if abs_gated.is_empty() {
return f64::NEG_INFINITY;
}
let abs_mean: f64 = abs_gated.iter().map(|&(_, p)| p).sum::<f64>() / abs_gated.len() as f64;
let abs_lufs = Self::power_to_lufs(abs_mean);
let rel_gate = abs_lufs + RELATIVE_GATE_OFFSET;
let rel_gated: Vec<f64> = abs_gated
.iter()
.filter(|&&(lufs, _)| lufs >= rel_gate)
.map(|&(_, p)| p)
.collect();
if rel_gated.is_empty() {
return f64::NEG_INFINITY;
}
let rel_mean: f64 = rel_gated.iter().sum::<f64>() / rel_gated.len() as f64;
Self::power_to_lufs(rel_mean)
}
pub fn loudness_range_lu(&self) -> f64 {
let mut lufs_vals: Vec<f64> = self
.gating_blocks
.iter()
.map(|&(lufs, _)| lufs)
.filter(|&lufs| lufs >= ABSOLUTE_GATE && lufs.is_finite())
.collect();
if lufs_vals.len() < 2 {
return 0.0;
}
lufs_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = lufs_vals.len();
let idx10 = ((n as f64 * 0.10).floor() as usize).min(n - 1);
let idx95 = ((n as f64 * 0.95).floor() as usize).min(n - 1);
lufs_vals[idx95] - lufs_vals[idx10]
}
pub fn true_peak_dbtp(&self) -> f64 {
self.tp_detector.max_true_peak_dbtp()
}
pub fn reset(&mut self) {
for f in &mut self.k_filters {
f.reset();
}
for v in &mut self.hop_accumulator {
*v = 0.0;
}
self.hop_count = 0;
self.momentary_buf.clear();
self.short_term_buf.clear();
self.momentary_sum = 0.0;
self.short_term_sum = 0.0;
self.momentary_lufs = f64::NEG_INFINITY;
self.momentary_max = f64::NEG_INFINITY;
self.short_term_lufs = f64::NEG_INFINITY;
self.short_term_max = f64::NEG_INFINITY;
self.gating_blocks.clear();
self.hop_powers.clear();
self.tp_detector.reset();
}
#[inline]
fn power_to_lufs(power: f64) -> f64 {
if power > 0.0 {
-0.691 + 10.0 * power.log10()
} else {
f64::NEG_INFINITY
}
}
fn channel_weights(channels: usize) -> Vec<f64> {
match channels {
1 => vec![1.0],
2 => vec![1.0, 1.0],
3 => vec![1.0, 1.0, 1.0],
4 => vec![1.0, 1.0, 1.0, 0.0],
5 => vec![1.0, 1.0, 1.0, 1.41, 1.41],
6 => vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41],
7 => vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41],
8 => vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41],
_ => vec![1.0; channels],
}
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn channels(&self) -> u32 {
self.channels
}
}
#[derive(Clone, Debug)]
pub struct LoudnessReport {
pub integrated_lufs: f64,
pub momentary_max_lufs: f64,
pub short_term_max_lufs: f64,
pub loudness_range_lu: f64,
pub true_peak_dbtp: f64,
pub complies_ebu_r128: bool,
pub complies_atsc_a85: bool,
pub complies_arib_tr_b32: bool,
}
impl LoudnessReport {
pub fn from_meter(meter: &EbuR128Meter) -> Self {
let integrated = meter.integrated_lufs();
let momentary_max = meter.momentary_max;
let short_term_max = meter.short_term_max;
let lra = meter.loudness_range_lu();
let tp = meter.true_peak_dbtp();
let complies_ebu = integrated.is_finite()
&& (integrated - (-23.0)).abs() <= 1.0
&& lra <= 20.0
&& tp <= -1.0;
let complies_atsc =
integrated.is_finite() && (integrated - (-24.0)).abs() <= 2.0 && tp <= -2.0;
let complies_arib = integrated.is_finite() && (integrated - (-24.0)).abs() <= 1.0;
Self {
integrated_lufs: integrated,
momentary_max_lufs: momentary_max,
short_term_max_lufs: short_term_max,
loudness_range_lu: lra,
true_peak_dbtp: tp,
complies_ebu_r128: complies_ebu,
complies_atsc_a85: complies_atsc,
complies_arib_tr_b32: complies_arib,
}
}
pub fn format_summary(&self) -> String {
format!(
"I={:.1} LUFS LRA={:.1} LU TP={:.1} dBTP [EBU:{} ATSC:{} ARIB:{}]",
self.integrated_lufs,
self.loudness_range_lu,
self.true_peak_dbtp,
if self.complies_ebu_r128 { "OK" } else { "FAIL" },
if self.complies_atsc_a85 { "OK" } else { "FAIL" },
if self.complies_arib_tr_b32 {
"OK"
} else {
"FAIL"
},
)
}
pub fn recommended_gain_db(&self, target_lufs: f64) -> f64 {
if self.integrated_lufs.is_finite() {
target_lufs - self.integrated_lufs
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn mono_sine(
freq_hz: f64,
amplitude_dbfs: f64,
sample_rate: u32,
n_samples: usize,
) -> Vec<f32> {
let amplitude = 10.0_f64.powf(amplitude_dbfs / 20.0);
let fs = f64::from(sample_rate);
(0..n_samples)
.map(|n| (amplitude * (2.0 * PI * freq_hz * n as f64 / fs).sin()) as f32)
.collect()
}
#[test]
fn test_k_weight_filter_constructs_for_48k() {
let _ = KWeightingFilter::new(48_000);
}
#[test]
fn test_k_weight_filter_constructs_for_44100() {
let _ = KWeightingFilter::new(44_100);
}
#[test]
fn test_k_weight_filter_constructs_for_96k() {
let _ = KWeightingFilter::new(96_000);
}
#[test]
fn test_k_weight_flat_at_997hz() {
let sample_rate = 48_000_u32;
let mut filter = KWeightingFilter::new(sample_rate);
let warmup = mono_sine(997.0, 0.0, sample_rate, sample_rate as usize / 10);
for &s in &warmup {
filter.process(f64::from(s));
}
let n = sample_rate as usize / 20;
let phase_offset = warmup.len();
let fs = f64::from(sample_rate);
let mut sum_in_sq = 0.0;
let mut sum_out_sq = 0.0;
for i in 0..n {
let s = (2.0 * PI * 997.0 * (phase_offset + i) as f64 / fs).sin();
let out = filter.process(s);
sum_in_sq += s * s;
sum_out_sq += out * out;
}
let gain_db = 10.0 * (sum_out_sq / sum_in_sq).log10();
assert!(
gain_db.abs() < 1.0,
"K-weighting gain at 997 Hz = {gain_db:.3} dB, expected ≈ 0 dB"
);
}
#[test]
fn test_k_weight_filter_reset_clears_state() {
let mut filter = KWeightingFilter::new(48_000);
for _ in 0..100 {
filter.process(1.0);
}
filter.reset();
let out = filter.process(0.0);
assert_eq!(out, 0.0, "after reset, 0.0 input should give 0.0 output");
}
#[test]
fn test_k_weight_process_block() {
let mut filter = KWeightingFilter::new(48_000);
let input: Vec<f64> = (0..1024)
.map(|n| (2.0 * PI * 1000.0 * n as f64 / 48_000.0).sin())
.collect();
let output = filter.process_block(&input);
assert_eq!(output.len(), input.len());
assert!(output.iter().all(|x| x.is_finite()));
}
#[test]
fn test_biquad_silence_in_silence_out() {
let mut bq = Biquad::new([1.0, 0.0, 0.0], [0.0, 0.0]);
assert_eq!(bq.process(0.0), 0.0);
}
#[test]
fn test_biquad_reset() {
let mut bq = Biquad::new([1.0, 0.0, 0.0], [0.0, 0.0]);
bq.process(0.5);
bq.reset();
assert_eq!(bq.w1, 0.0);
assert_eq!(bq.w2, 0.0);
}
#[test]
fn test_biquad_identity_filter() {
let mut bq = Biquad::new([1.0, 0.0, 0.0], [0.0, 0.0]);
for x in [0.1, -0.5, 0.9, 0.0, 1.0] {
assert!(
(bq.process(x) - x).abs() < 1e-12,
"identity biquad failed for x={x}"
);
}
}
#[test]
fn test_tp_detector_silence_gives_neg_inf() {
let mut tp = TruePeakDetector::new(48_000);
let silence = vec![0.0f32; 4800];
for &s in &silence {
tp.process_sample(s);
}
assert!(
tp.max_true_peak_dbtp().is_infinite(),
"silence should give −∞ dBTP"
);
}
#[test]
fn test_tp_detector_full_scale_sine() {
let mut tp = TruePeakDetector::new(48_000);
let sr = 48_000_u32;
let samples = mono_sine(997.0, 0.0, sr, sr as usize / 2);
for &s in &samples {
tp.process_sample(s);
}
let tp_db = tp.max_true_peak_dbtp();
assert!(
tp_db > -1.0 && tp_db <= 1.0,
"TP of full-scale sine = {tp_db:.2} dBTP"
);
}
#[test]
fn test_tp_detector_reset() {
let mut tp = TruePeakDetector::new(48_000);
let samples = mono_sine(997.0, 0.0, 48_000, 4_800);
for &s in &samples {
tp.process_sample(s);
}
tp.reset();
assert!(
tp.max_true_peak_dbtp().is_infinite(),
"after reset, TP should be −∞"
);
}
#[test]
fn test_tp_detector_returns_finite_for_signal() {
let mut tp = TruePeakDetector::new(48_000);
let samples = mono_sine(997.0, -6.0, 48_000, 4800);
for &s in &samples {
tp.process_sample(s);
}
assert!(tp.max_true_peak_dbtp().is_finite());
}
#[test]
fn test_meter_constructs() {
let _ = EbuR128Meter::new(48_000, 1);
let _ = EbuR128Meter::new(48_000, 2);
let _ = EbuR128Meter::new(44_100, 2);
}
#[test]
fn test_momentary_lufs_997hz_minus3dbfs() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -3.0, sr, sr as usize / 2);
meter.process(&samples);
let m = meter.momentary_lufs();
assert!(
m.is_finite(),
"momentary LUFS should be finite after 0.5 s of signal"
);
assert!(
m > -8.2 && m < -5.2,
"momentary LUFS = {m:.2}, expected ≈ −6.7 LUFS for 997 Hz @ −3 dBFS peak"
);
}
#[test]
fn test_silence_gives_neg_infinity() {
let mut meter = EbuR128Meter::new(48_000, 1);
let silence = vec![0.0f32; 24_000];
meter.process(&silence);
let i = meter.integrated_lufs();
assert!(
i.is_infinite() && i.is_sign_negative(),
"silence: integrated LUFS should be −∞, got {i}"
);
}
#[test]
fn test_meter_reset_clears_state() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -3.0, sr, sr as usize / 2);
meter.process(&samples);
assert!(meter.momentary_lufs().is_finite());
meter.reset();
assert!(
meter.momentary_lufs().is_infinite(),
"after reset, momentary should be −∞"
);
assert!(
meter.integrated_lufs().is_infinite(),
"after reset, integrated should be −∞"
);
assert!(
meter.true_peak_dbtp().is_infinite(),
"after reset, true peak should be −∞"
);
}
#[test]
fn test_integrated_lufs_approaches_momentary_for_steady_tone() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -18.0, sr, sr as usize);
meter.process(&samples);
let m = meter.momentary_lufs();
let i = meter.integrated_lufs();
assert!(m.is_finite() && i.is_finite());
assert!(
(m - i).abs() < 2.0,
"momentary={m:.2}, integrated={i:.2}; should agree within 2 LU"
);
}
#[test]
fn test_stereo_meter() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 2);
let mono = mono_sine(997.0, -6.0, sr, sr as usize / 2);
let stereo: Vec<f32> = mono.iter().flat_map(|&s| [s, s]).collect();
meter.process(&stereo);
assert!(
meter.momentary_lufs().is_finite(),
"stereo momentary should be finite"
);
}
#[test]
fn test_short_term_lufs_valid_after_3s() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -12.0, sr, sr as usize * 3);
meter.process(&samples);
assert!(
meter.short_term_lufs().is_finite(),
"short-term LUFS should be finite after 3 s of signal"
);
}
#[test]
fn test_loudness_range_zero_for_short_signal() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -12.0, sr, sr as usize / 2);
meter.process(&samples);
let lra = meter.loudness_range_lu();
assert!(lra >= 0.0, "LRA must be non-negative");
}
#[test]
fn test_true_peak_detected_above_signal_peak() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, 0.0, sr, sr as usize / 2);
meter.process(&samples);
let tp = meter.true_peak_dbtp();
assert!(
tp.is_finite(),
"true peak should be finite for full-scale signal"
);
}
#[test]
fn test_report_from_meter() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -23.0, sr, sr as usize);
meter.process(&samples);
let report = LoudnessReport::from_meter(&meter);
assert!(report.integrated_lufs.is_finite());
assert!(report.loudness_range_lu >= 0.0);
}
#[test]
fn test_report_format_summary_contains_lufs() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -23.0, sr, sr as usize);
meter.process(&samples);
let report = LoudnessReport::from_meter(&meter);
let summary = report.format_summary();
assert!(
summary.contains("LUFS"),
"summary should contain 'LUFS': {summary}"
);
}
#[test]
fn test_report_recommended_gain() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -22.0, sr, sr as usize);
meter.process(&samples);
let report = LoudnessReport::from_meter(&meter);
let gain = report.recommended_gain_db(-23.0);
assert!(
gain > 0.5 && gain < 5.0,
"recommended gain = {gain:.2} dB, expected ~+2.7 dB"
);
}
#[test]
fn test_report_silence_recommended_gain_zero() {
let mut meter = EbuR128Meter::new(48_000, 1);
meter.process(&vec![0.0f32; 48_000]);
let report = LoudnessReport::from_meter(&meter);
assert_eq!(report.recommended_gain_db(-23.0), 0.0);
}
#[test]
fn test_ebu_compliance_for_target_signal() {
let sr = 48_000_u32;
let mut meter = EbuR128Meter::new(sr, 1);
let samples = mono_sine(997.0, -19.3, sr, sr as usize * 2);
meter.process(&samples);
let report = LoudnessReport::from_meter(&meter);
assert!(
report.complies_ebu_r128,
"should comply with EBU R128; I={:.2}, LRA={:.2}, TP={:.2}",
report.integrated_lufs, report.loudness_range_lu, report.true_peak_dbtp
);
}
#[test]
fn test_sample_rate_accessor() {
let meter = EbuR128Meter::new(44_100, 2);
assert_eq!(meter.sample_rate(), 44_100);
}
#[test]
fn test_channel_accessor() {
let meter = EbuR128Meter::new(48_000, 6);
assert_eq!(meter.channels(), 6);
}
#[test]
fn test_stage1_48k_coefficients() {
let filter = KWeightingFilter::new(48_000);
let mut f = filter;
let y0 = f.process(1.0);
let y1 = f.process(0.0);
let y2 = f.process(0.0);
assert!(y0 > 1.0 && y0 < 2.0, "impulse response[0]={y0:.4}");
assert!(y1.abs() < y0.abs() * 2.0 && y2.is_finite());
}
#[test]
fn test_stage2_attenuates_dc() {
let mut filter = KWeightingFilter::new(48_000);
let mut last = 0.0;
for _ in 0..10_000 {
last = filter.process(1.0);
}
assert!(
last.abs() < 0.01,
"Stage 2 should attenuate DC; last output = {last:.6}"
);
}
#[test]
fn test_stage1_shelf_boosts_highs() {
let sr = 48_000_u32;
let fs = f64::from(sr);
let (b1, a1) = KWeightingFilter::design_stage1(fs);
let mut s1 = Biquad::new(b1, a1);
for _ in 0..4800 {
s1.process((2.0 * PI * 1000.0 / fs).sin());
}
let n = 4800_usize;
let mut sum_in_1k = 0.0;
let mut sum_out_1k = 0.0;
for i in 0..n {
let x = (2.0 * PI * 1000.0 * i as f64 / fs).sin();
let y = s1.process(x);
sum_in_1k += x * x;
sum_out_1k += y * y;
}
let (b1, a1) = KWeightingFilter::design_stage1(fs);
let mut s1_8k = Biquad::new(b1, a1);
for _ in 0..4800 {
s1_8k.process((2.0 * PI * 8000.0 / fs).sin());
}
let mut sum_in_8k = 0.0;
let mut sum_out_8k = 0.0;
for i in 0..n {
let x = (2.0 * PI * 8000.0 * i as f64 / fs).sin();
let y = s1_8k.process(x);
sum_in_8k += x * x;
sum_out_8k += y * y;
}
let gain_1k_db = 10.0 * (sum_out_1k / sum_in_1k).log10();
let gain_8k_db = 10.0 * (sum_out_8k / sum_in_8k).log10();
assert!(
gain_8k_db > gain_1k_db + 2.0,
"Stage 1 shelf gain 8kHz={gain_8k_db:.2} dB, 1kHz={gain_1k_db:.2} dB; expected 8k > 1k + 2dB"
);
}
}