extern crate alloc;
use alloc::vec;
use alloc::vec::Vec;
use resonant_filters::biquad::{Biquad, BiquadCoeffs};
use resonant_filters::{design, PolyphaseResampler};
use crate::AnalysisError;
#[derive(Debug, Clone)]
pub struct ChannelConfig {
pub weights: Vec<f32>,
}
impl ChannelConfig {
#[must_use]
pub fn mono() -> Self {
Self { weights: vec![1.0] }
}
#[must_use]
pub fn stereo() -> Self {
Self {
weights: vec![1.0, 1.0],
}
}
#[must_use]
pub fn surround_5_1() -> Self {
use core::f32::consts::SQRT_2;
Self {
weights: vec![1.0, 1.0, 1.0, 0.0, SQRT_2, SQRT_2],
}
}
}
const SILENCE_LUFS: f32 = -120.0;
const ABS_GATE_LUFS: f32 = -70.0;
const REL_GATE_OFFSET_LU: f32 = 10.0;
const PRE_44100: BiquadCoeffs = BiquadCoeffs {
b0: 1.530_926_5_f32,
b1: -2.651_179_f32,
b2: 1.169_068_2_f32,
a1: -1.663_758_3_f32,
a2: 0.712_653_96_f32,
};
const RLB_44100: BiquadCoeffs = BiquadCoeffs {
b0: 1.0_f32,
b1: -2.0_f32,
b2: 1.0_f32,
a1: -1.988_378_9_f32,
a2: 0.988_520_47_f32,
};
const PRE_48000: BiquadCoeffs = BiquadCoeffs {
b0: 1.535_124_9_f32,
b1: -2.691_696_2_f32,
b2: 1.198_392_9_f32,
a1: -1.690_659_3_f32,
a2: 0.732_480_77_f32,
};
const RLB_48000: BiquadCoeffs = BiquadCoeffs {
b0: 1.0_f32,
b1: -2.0_f32,
b2: 1.0_f32,
a1: -1.990_047_5_f32,
a2: 0.990_072_25_f32,
};
pub struct LufsAnalyser {
sample_rate: f32,
pre: Biquad,
rlb: Biquad,
}
impl LufsAnalyser {
pub fn new(sample_rate: f32) -> Result<Self, AnalysisError> {
let (pre, rlb) = kweight_coeffs(sample_rate)?;
Ok(Self {
sample_rate,
pre: Biquad::new(pre),
rlb: Biquad::new(rlb),
})
}
pub fn integrated_loudness(&mut self, samples: &[f32]) -> Result<f32, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let block_samples = block_len(self.sample_rate);
let hop_samples = hop_len(self.sample_rate);
let kw_samples: Vec<f32> = samples.iter().map(|&x| self.k_weight(x)).collect();
let block_levels = block_mean_sq(&kw_samples, block_samples, hop_samples);
if block_levels.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let abs_gate_ms = lufs_to_ms(ABS_GATE_LUFS);
let abs_gated: Vec<f32> = block_levels
.iter()
.copied()
.filter(|&z| z >= abs_gate_ms)
.collect();
if abs_gated.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let rel_gate_lufs = ms_to_lufs(mean_f32(&abs_gated)) - REL_GATE_OFFSET_LU;
let rel_gate_ms = lufs_to_ms(rel_gate_lufs);
let rel_gated: Vec<f32> = block_levels
.iter()
.copied()
.filter(|&z| z >= rel_gate_ms)
.collect();
if rel_gated.is_empty() {
return Err(AnalysisError::EmptyInput);
}
Ok(ms_to_lufs(mean_f32(&rel_gated)))
}
#[must_use]
pub fn momentary(&mut self, frame_400ms: &[f32]) -> f32 {
ms_to_lufs(self.kw_mean_sq(frame_400ms))
}
#[must_use]
pub fn short_term(&mut self, frame_3s: &[f32]) -> f32 {
ms_to_lufs(self.kw_mean_sq(frame_3s))
}
pub fn true_peak_db(&self, samples: &[f32]) -> Result<f32, AnalysisError> {
if samples.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let mut resampler =
PolyphaseResampler::new(4, 1).ok_or(AnalysisError::InvalidParameter {
name: "true_peak",
reason: "failed to construct 4× polyphase resampler",
})?;
let upsampled = resampler.process(samples);
let peak = upsampled.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
if peak <= 0.0 {
return Ok(SILENCE_LUFS);
}
Ok(20.0 * peak.log10())
}
pub fn reset(&mut self) {
let pre_coeffs = *self.pre.coeffs();
let rlb_coeffs = *self.rlb.coeffs();
self.pre = Biquad::new(pre_coeffs);
self.rlb = Biquad::new(rlb_coeffs);
}
pub fn integrated_loudness_multichannel(
&mut self,
interleaved: &[f32],
config: &ChannelConfig,
) -> Result<f32, AnalysisError> {
let n_channels = config.weights.len();
if n_channels == 0 {
return Err(AnalysisError::InvalidParameter {
name: "config",
reason: "channel config has no weights",
});
}
if interleaved.is_empty() {
return Err(AnalysisError::EmptyInput);
}
if interleaved.len() % n_channels != 0 {
return Err(AnalysisError::InvalidParameter {
name: "interleaved",
reason: "sample count is not a multiple of channel count",
});
}
let n_frames = interleaved.len() / n_channels;
let block_samples = block_len(self.sample_rate);
let hop_samples = hop_len(self.sample_rate);
if n_frames < block_samples {
return Err(AnalysisError::EmptyInput);
}
let (pre_coeffs, rlb_coeffs) = kweight_coeffs(self.sample_rate)?;
let mut channel_filters: Vec<(Biquad, Biquad)> = (0..n_channels)
.map(|_| (Biquad::new(pre_coeffs), Biquad::new(rlb_coeffs)))
.collect();
let mut channel_kw_samples: Vec<Vec<f32>> = (0..n_channels)
.map(|_| Vec::with_capacity(n_frames))
.collect();
for frame in interleaved.chunks_exact(n_channels) {
for ((ch_kw_samples, (pre, rlb)), &sample) in channel_kw_samples
.iter_mut()
.zip(channel_filters.iter_mut())
.zip(frame.iter())
{
ch_kw_samples.push(rlb.process_sample(pre.process_sample(sample)));
}
}
let n_blocks = (n_frames - block_samples) / hop_samples + 1;
let block_levels: Vec<f32> = (0..n_blocks)
.map(|block_idx| {
let block_start = block_idx * hop_samples;
config
.weights
.iter()
.zip(channel_kw_samples.iter())
.map(|(&weight, ch_kw)| {
let block_slice = &ch_kw[block_start..block_start + block_samples];
let block_ms =
block_slice.iter().map(|&x| x * x).sum::<f32>() / block_samples as f32;
weight * block_ms
})
.sum()
})
.collect();
if block_levels.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let abs_gate_ms = lufs_to_ms(ABS_GATE_LUFS);
let abs_gated: Vec<f32> = block_levels
.iter()
.copied()
.filter(|&z| z >= abs_gate_ms)
.collect();
if abs_gated.is_empty() {
return Err(AnalysisError::EmptyInput);
}
let rel_gate_lufs = ms_to_lufs(mean_f32(&abs_gated)) - REL_GATE_OFFSET_LU;
let rel_gate_ms = lufs_to_ms(rel_gate_lufs);
let rel_gated: Vec<f32> = block_levels
.iter()
.copied()
.filter(|&z| z >= rel_gate_ms)
.collect();
if rel_gated.is_empty() {
return Err(AnalysisError::EmptyInput);
}
Ok(ms_to_lufs(mean_f32(&rel_gated)))
}
#[inline]
fn k_weight(&mut self, x: f32) -> f32 {
self.rlb.process_sample(self.pre.process_sample(x))
}
fn kw_mean_sq(&mut self, samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum_sq: f32 = samples
.iter()
.map(|&x| {
let y = self.k_weight(x);
y * y
})
.sum();
sum_sq / samples.len() as f32
}
}
fn kweight_coeffs(sr: f32) -> Result<(BiquadCoeffs, BiquadCoeffs), AnalysisError> {
match sr.round() as u32 {
44100 => Ok((PRE_44100, RLB_44100)),
48000 => Ok((PRE_48000, RLB_48000)),
88200 | 96000 => {
let pre =
design::shelving_high(4.0, 1681.97, sr).ok_or(AnalysisError::InvalidParameter {
name: "sample_rate",
reason: "K-weighting pre-filter design failed",
})?;
let rlb = design::butterworth_highpass(38.135_f64, f64::from(sr)).ok_or(
AnalysisError::InvalidParameter {
name: "sample_rate",
reason: "K-weighting RLB high-pass design failed",
},
)?;
Ok((pre, rlb))
}
_ => Err(AnalysisError::InvalidParameter {
name: "sample_rate",
reason: "supported rates: 44100, 48000, 88200, 96000",
}),
}
}
fn block_len(sr: f32) -> usize {
(sr * 0.4).round() as usize
}
fn hop_len(sr: f32) -> usize {
(sr * 0.1).round() as usize
}
fn block_mean_sq(signal: &[f32], block: usize, hop: usize) -> Vec<f32> {
if block == 0 || hop == 0 || signal.len() < block {
return vec![];
}
let n_blocks = (signal.len() - block) / hop + 1;
(0..n_blocks)
.map(|i| {
let block_slice = &signal[i * hop..i * hop + block];
block_slice.iter().map(|&x| x * x).sum::<f32>() / block as f32
})
.collect()
}
fn ms_to_lufs(mean_sq: f32) -> f32 {
if mean_sq <= 0.0 {
SILENCE_LUFS
} else {
(-0.691 + 10.0 * mean_sq.log10()).max(SILENCE_LUFS)
}
}
fn lufs_to_ms(lufs: f32) -> f32 {
10f32.powf((lufs + 0.691) / 10.0)
}
fn mean_f32(v: &[f32]) -> f32 {
v.iter().sum::<f32>() / v.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
use core::f32::consts::PI;
const SR: f32 = 44100.0;
fn sine(freq: f32, amplitude: f32, num_samples: usize, sr: f32) -> Vec<f32> {
(0..num_samples)
.map(|i| amplitude * (2.0 * PI * freq * i as f32 / sr).sin())
.collect()
}
fn make(sr: f32) -> LufsAnalyser {
match LufsAnalyser::new(sr) {
Ok(analyser) => analyser,
Err(e) => panic!("LufsAnalyser::new({sr}) failed: {e}"),
}
}
#[test]
fn new_44100_succeeds() {
assert!(LufsAnalyser::new(44100.0).is_ok());
}
#[test]
fn new_48000_succeeds() {
assert!(LufsAnalyser::new(48000.0).is_ok());
}
#[test]
fn new_88200_succeeds() {
assert!(LufsAnalyser::new(88200.0).is_ok());
}
#[test]
fn new_96000_succeeds() {
assert!(LufsAnalyser::new(96000.0).is_ok());
}
#[test]
fn unsupported_rate_returns_error() {
assert!(LufsAnalyser::new(22050.0).is_err());
assert!(LufsAnalyser::new(16000.0).is_err());
assert!(LufsAnalyser::new(0.0).is_err());
}
#[test]
fn empty_input_returns_error() {
let mut analyser = make(SR);
assert!(matches!(
analyser.integrated_loudness(&[]),
Err(AnalysisError::EmptyInput)
));
}
#[test]
fn silence_gated_out() {
let mut analyser = make(SR);
let result = analyser.integrated_loudness(&vec![0.0_f32; 44100 * 5]);
assert!(matches!(result, Err(AnalysisError::EmptyInput)));
}
#[test]
fn too_short_for_one_block() {
let mut analyser = make(SR);
let result = analyser.integrated_loudness(&[0.1_f32; 100]);
assert!(matches!(result, Err(AnalysisError::EmptyInput)));
}
#[test]
fn integrated_lufs_calibration_tone() {
let num_samples = (SR * 5.0) as usize;
let sig = sine(1000.0, 0.10, num_samples, SR);
let mut analyser = make(SR);
match analyser.integrated_loudness(&sig) {
Ok(lufs) => assert!(
(lufs - (-23.0)).abs() < 0.2,
"expected ≈ −23 LUFS, got {lufs:.3}"
),
Err(e) => panic!("unexpected error: {e}"),
}
}
#[test]
fn integrated_lufs_6lu_per_6db() {
let num_samples = (SR * 5.0) as usize;
let signal_quiet = sine(1000.0, 0.10, num_samples, SR);
let signal_loud = sine(1000.0, 0.20, num_samples, SR);
let mut analyser = make(SR);
let lufs_quiet = match analyser.integrated_loudness(&signal_quiet) {
Ok(lufs) => lufs,
Err(e) => panic!("quiet signal error: {e}"),
};
analyser.reset();
let lufs_loud = match analyser.integrated_loudness(&signal_loud) {
Ok(lufs) => lufs,
Err(e) => panic!("loud signal error: {e}"),
};
assert!(
(lufs_loud - lufs_quiet - 6.02).abs() < 0.05,
"doubling amplitude should give +6.02 LU, got {:.3}",
lufs_loud - lufs_quiet
);
}
#[test]
fn true_peak_full_scale_sine_near_zero_dbtp() {
let num_samples = SR as usize;
let sig: Vec<f32> = (0..num_samples)
.map(|i| (2.0 * PI * 997.0 * i as f32 / SR).sin())
.collect();
let analyser = make(SR);
match analyser.true_peak_db(&sig) {
Ok(true_peak) => assert!(
true_peak.abs() < 0.5,
"full-scale 997 Hz sine ≈ 0 dBTP, got {true_peak:.3}"
),
Err(e) => panic!("unexpected error: {e}"),
}
}
#[test]
fn true_peak_empty_returns_error() {
let analyser = make(SR);
assert!(matches!(
analyser.true_peak_db(&[]),
Err(AnalysisError::EmptyInput)
));
}
#[test]
fn momentary_reasonable_for_sine() {
let block_samples = block_len(SR);
let sig = sine(1000.0, 0.10, block_samples, SR);
let momentary_lufs = {
let mut analyser = make(SR);
analyser.momentary(&sig)
};
assert!(
momentary_lufs > -35.0 && momentary_lufs < -15.0,
"momentary LUFS out of range: {momentary_lufs:.2}"
);
}
#[test]
fn short_term_reasonable_for_sine() {
let num_samples = (SR * 3.0) as usize;
let sig = sine(1000.0, 0.10, num_samples, SR);
let short_term_lufs = {
let mut analyser = make(SR);
analyser.short_term(&sig)
};
assert!(
short_term_lufs > -35.0 && short_term_lufs < -15.0,
"short-term LUFS out of range: {short_term_lufs:.2}"
);
}
#[test]
fn reset_clears_filter_state() {
let block_samples = block_len(SR);
let pre_warm = sine(100.0, 1.0, block_samples, SR);
let test_sig = sine(1000.0, 0.10, block_samples, SR);
let lufs_after_reset = {
let mut analyser = make(SR);
let _ = analyser.momentary(&pre_warm);
analyser.reset();
analyser.momentary(&test_sig)
};
let lufs_fresh = {
let mut analyser = make(SR);
analyser.momentary(&test_sig)
};
assert!(
(lufs_after_reset - lufs_fresh).abs() < 1e-5,
"reset should give same result as fresh: {lufs_after_reset:.6} vs {lufs_fresh:.6}"
);
}
#[test]
fn block_mean_sq_constant_signal() {
let sig = vec![0.5_f32; 100];
let block_levels = block_mean_sq(&sig, 50, 25);
assert_eq!(block_levels.len(), 3);
for level in &block_levels {
assert!((level - 0.25).abs() < 1e-6, "expected 0.25, got {level}");
}
}
#[test]
fn block_mean_sq_too_short() {
assert!(block_mean_sq(&[1.0_f32; 10], 50, 25).is_empty());
}
#[test]
fn ms_to_lufs_known_value() {
let lufs = ms_to_lufs(0.5);
assert!((lufs - (-3.701)).abs() < 0.001, "got {lufs:.4}");
}
#[test]
fn ms_to_lufs_silence() {
assert_eq!(ms_to_lufs(0.0), SILENCE_LUFS);
assert_eq!(ms_to_lufs(-1.0), SILENCE_LUFS);
}
#[test]
fn lufs_to_ms_roundtrip() {
let lufs = -23.0_f32;
let mean_sq = lufs_to_ms(lufs);
let roundtripped_lufs = ms_to_lufs(mean_sq);
assert!(
(roundtripped_lufs - lufs).abs() < 0.001,
"roundtrip: {lufs} → {roundtripped_lufs:.4}"
);
}
#[test]
fn multichannel_empty_input_returns_error() {
let mut analyser = make(SR);
assert!(matches!(
analyser.integrated_loudness_multichannel(&[], &ChannelConfig::stereo()),
Err(AnalysisError::EmptyInput)
));
}
#[test]
fn multichannel_empty_config_returns_error() {
let mut analyser = make(SR);
let config = ChannelConfig { weights: vec![] };
assert!(matches!(
analyser.integrated_loudness_multichannel(&[0.1_f32; 100], &config),
Err(AnalysisError::InvalidParameter { .. })
));
}
#[test]
fn multichannel_mismatched_samples_returns_error() {
let mut analyser = make(SR);
assert!(matches!(
analyser.integrated_loudness_multichannel(&[0.1_f32; 3], &ChannelConfig::stereo()),
Err(AnalysisError::InvalidParameter { .. })
));
}
#[test]
fn multichannel_mono_matches_integrated_loudness() {
let num_samples = (SR * 5.0) as usize;
let sig = sine(1000.0, 0.10, num_samples, SR);
let mut analyser_mono = make(SR);
let mut analyser_mc = make(SR);
let lufs_mono = analyser_mono
.integrated_loudness(&sig)
.unwrap_or_else(|e| panic!("integrated_loudness error: {e}"));
let lufs_mc = analyser_mc
.integrated_loudness_multichannel(&sig, &ChannelConfig::mono())
.unwrap_or_else(|e| panic!("multichannel mono error: {e}"));
assert!(
(lufs_mono - lufs_mc).abs() < 0.01,
"mono config should match integrated_loudness: {lufs_mono:.3} vs {lufs_mc:.3}"
);
}
#[test]
fn stereo_identical_channels_is_three_lu_above_mono() {
let num_samples = (SR * 5.0) as usize;
let mono = sine(1000.0, 0.10, num_samples, SR);
let stereo: Vec<f32> = mono.iter().flat_map(|&s| [s, s]).collect();
let mut analyser_mono = make(SR);
let mut analyser_stereo = make(SR);
let lufs_mono = analyser_mono
.integrated_loudness(&mono)
.unwrap_or_else(|e| panic!("mono error: {e}"));
let lufs_stereo = analyser_stereo
.integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo())
.unwrap_or_else(|e| panic!("stereo error: {e}"));
let diff = lufs_stereo - lufs_mono;
assert!(
(diff - 3.01).abs() < 0.1,
"identical stereo should be +3.01 LU above mono, got {diff:.3} LU"
);
}
#[test]
fn surround_5_1_lfe_only_is_gated_out() {
let num_samples = (SR * 5.0) as usize;
let lfe_signal = sine(60.0, 0.5, num_samples, SR);
let interleaved: Vec<f32> = (0..num_samples)
.flat_map(|i| [0.0, 0.0, 0.0, lfe_signal[i], 0.0, 0.0])
.collect();
let mut analyser = make(SR);
assert!(
matches!(
analyser
.integrated_loudness_multichannel(&interleaved, &ChannelConfig::surround_5_1()),
Err(AnalysisError::EmptyInput)
),
"LFE-only 5.1 should be gated out"
);
}
#[test]
fn stereo_silence_gated_out() {
let num_samples = (SR * 5.0) as usize;
let stereo = vec![0.0_f32; num_samples * 2];
let mut analyser = make(SR);
assert!(matches!(
analyser.integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo()),
Err(AnalysisError::EmptyInput)
));
}
#[test]
fn stereo_too_short_for_one_block_returns_error() {
let mut analyser = make(SR);
let stereo = vec![0.1_f32; 200]; assert!(matches!(
analyser.integrated_loudness_multichannel(&stereo, &ChannelConfig::stereo()),
Err(AnalysisError::EmptyInput)
));
}
}