const LOUDNESS_OFFSET: f64 = -0.691;
const ABSOLUTE_GATE_LUFS: f64 = -70.0;
const RELATIVE_GATE_FACTOR: f64 = 0.1;
fn energy_to_loudness(energy: f64) -> f64 {
LOUDNESS_OFFSET + 10.0 * energy.log10()
}
fn loudness_to_energy(lufs: f64) -> f64 {
10f64.powf((lufs - LOUDNESS_OFFSET) / 10.0)
}
#[derive(Clone)]
struct Biquad {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
z1: f64,
z2: f64,
}
impl Biquad {
fn new(coeffs: (f64, f64, f64, f64, f64)) -> Self {
let (b0, b1, b2, a1, a2) = coeffs;
Self {
b0,
b1,
b2,
a1,
a2,
z1: 0.0,
z2: 0.0,
}
}
#[inline]
fn process(&mut self, x: f64) -> f64 {
let y = self.b0 * x + self.z1;
self.z1 = self.b1 * x - self.a1 * y + self.z2;
self.z2 = self.b2 * x - self.a2 * y;
y
}
}
fn shelf_coefficients(sample_rate: f64) -> (f64, f64, f64, f64, f64) {
let f0 = 1681.974450955533;
let gain_db = 3.999843853973347;
let q = 0.7071752369554196;
let k = (std::f64::consts::PI * f0 / sample_rate).tan();
let vh = 10f64.powf(gain_db / 20.0);
let vb = vh.powf(0.4996667741545416);
let a0 = 1.0 + k / q + k * k;
(
(vh + vb * k / q + k * k) / a0,
2.0 * (k * k - vh) / a0,
(vh - vb * k / q + k * k) / a0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / q + k * k) / a0,
)
}
fn highpass_coefficients(sample_rate: f64) -> (f64, f64, f64, f64, f64) {
let f0 = 38.13547087602444;
let q = 0.5003270373238773;
let k = (std::f64::consts::PI * f0 / sample_rate).tan();
let a0 = 1.0 + k / q + k * k;
(
1.0,
-2.0,
1.0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / q + k * k) / a0,
)
}
#[derive(Clone)]
struct KWeightingFilter {
shelf: Biquad,
highpass: Biquad,
}
impl KWeightingFilter {
fn new(sample_rate: u32) -> Self {
let fs = sample_rate as f64;
Self {
shelf: Biquad::new(shelf_coefficients(fs)),
highpass: Biquad::new(highpass_coefficients(fs)),
}
}
#[inline]
fn process(&mut self, sample: f64) -> f64 {
self.highpass.process(self.shelf.process(sample))
}
}
fn channel_weights(channels: usize) -> Vec<f64> {
match channels {
4 => vec![1.0, 1.0, 1.41, 1.41],
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],
n => vec![1.0; n],
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BlockEnergies {
energies: Vec<f64>,
}
impl BlockEnergies {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.energies.len()
}
pub fn is_empty(&self) -> bool {
self.energies.is_empty()
}
pub fn accumulate(&mut self, other: &BlockEnergies) {
self.energies.extend_from_slice(&other.energies);
}
pub fn integrated_lufs(&self) -> f64 {
let absolute_gate = loudness_to_energy(ABSOLUTE_GATE_LUFS);
let Some(ungated_mean) = gated_mean(&self.energies, absolute_gate) else {
return f64::NEG_INFINITY;
};
let gate = absolute_gate.max(ungated_mean * RELATIVE_GATE_FACTOR);
match gated_mean(&self.energies, gate) {
Some(mean) => energy_to_loudness(mean),
None => f64::NEG_INFINITY,
}
}
}
fn gated_mean(energies: &[f64], gate: f64) -> Option<f64> {
let mut sum = 0.0;
let mut count = 0usize;
for &e in energies {
if e > gate {
sum += e;
count += 1;
}
}
(count > 0).then(|| sum / count as f64)
}
pub struct Bs1770Analyzer {
filters: Vec<KWeightingFilter>,
weights: Vec<f64>,
subblock_len: usize,
subblock_sum: f64,
subblock_samples: usize,
recent: [f64; 3],
recent_len: usize,
blocks: BlockEnergies,
}
impl Bs1770Analyzer {
pub fn new(sample_rate: u32, channels: usize) -> Self {
let channels = channels.max(1);
Self {
filters: vec![KWeightingFilter::new(sample_rate); channels],
weights: channel_weights(channels),
subblock_len: (sample_rate as usize + 5) / 10,
subblock_sum: 0.0,
subblock_samples: 0,
recent: [0.0; 3],
recent_len: 0,
blocks: BlockEnergies::new(),
}
}
#[inline]
pub fn add_frame(&mut self, frame: &[f64]) {
let mut acc = 0.0;
for ((filter, &weight), &sample) in self.filters.iter_mut().zip(&self.weights).zip(frame) {
let y = filter.process(sample);
acc += weight * y * y;
}
self.subblock_sum += acc;
self.subblock_samples += 1;
if self.subblock_samples >= self.subblock_len {
self.finish_subblock();
}
}
fn finish_subblock(&mut self) {
let sum = self.subblock_sum;
if self.recent_len == 3 {
let block_sum = self.recent.iter().sum::<f64>() + sum;
self.blocks
.energies
.push(block_sum / (4 * self.subblock_len) as f64);
self.recent.rotate_left(1);
self.recent[2] = sum;
} else {
self.recent[self.recent_len] = sum;
self.recent_len += 1;
}
self.subblock_sum = 0.0;
self.subblock_samples = 0;
}
pub fn into_blocks(self) -> BlockEnergies {
self.blocks
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOLERANCE: f64 = 0.1;
#[test]
fn coefficients_match_bs1770_reference_at_48khz() {
let (b0, b1, b2, a1, a2) = shelf_coefficients(48000.0);
assert!((b0 - 1.53512485958697).abs() < 1e-6);
assert!((b1 - -2.69169618940638).abs() < 1e-6);
assert!((b2 - 1.19839281085285).abs() < 1e-6);
assert!((a1 - -1.69065929318241).abs() < 1e-6);
assert!((a2 - 0.73248077421585).abs() < 1e-6);
let (b0, b1, b2, a1, a2) = highpass_coefficients(48000.0);
assert_eq!(b0, 1.0);
assert_eq!(b1, -2.0);
assert_eq!(b2, 1.0);
assert!((a1 - -1.99004745483398).abs() < 1e-5);
assert!((a2 - 0.99007225036621).abs() < 1e-5);
}
fn append_sine(samples: &mut Vec<f64>, level_dbfs: f64, seconds: f64, sample_rate: u32) {
let amplitude = crate::gain::db_to_linear(level_dbfs);
let count = (seconds * sample_rate as f64) as usize;
let step = 2.0 * std::f64::consts::PI * 997.0 / sample_rate as f64;
for n in 0..count {
samples.push(amplitude * (step * n as f64).sin());
}
}
fn integrated_stereo(samples: &[f64], sample_rate: u32) -> f64 {
let mut analyzer = Bs1770Analyzer::new(sample_rate, 2);
for &s in samples {
analyzer.add_frame(&[s, s]);
}
analyzer.into_blocks().integrated_lufs()
}
#[test]
fn tech3341_case1_minus23_sine() {
for &rate in &[48000u32, 44100] {
let mut samples = Vec::new();
append_sine(&mut samples, -23.0, 20.0, rate);
let lufs = integrated_stereo(&samples, rate);
assert!(
(lufs - -23.0).abs() < TOLERANCE,
"expected -23 LUFS at {} Hz, got {:.3}",
rate,
lufs
);
}
}
#[test]
fn tech3341_case2_minus33_sine() {
let mut samples = Vec::new();
append_sine(&mut samples, -33.0, 20.0, 48000);
let lufs = integrated_stereo(&samples, 48000);
assert!((lufs - -33.0).abs() < TOLERANCE, "got {:.3}", lufs);
}
#[test]
fn relative_gate_excludes_quiet_passages() {
let mut samples = Vec::new();
append_sine(&mut samples, -36.0, 2.5, 48000);
append_sine(&mut samples, -23.0, 15.0, 48000);
append_sine(&mut samples, -36.0, 2.5, 48000);
let lufs = integrated_stereo(&samples, 48000);
assert!((lufs - -23.0).abs() < TOLERANCE, "got {:.3}", lufs);
}
#[test]
fn absolute_gate_excludes_silence() {
let mut samples = vec![0.0; 5 * 48000];
append_sine(&mut samples, -23.0, 20.0, 48000);
samples.extend(std::iter::repeat(0.0).take(5 * 48000));
let lufs = integrated_stereo(&samples, 48000);
assert!((lufs - -23.0).abs() < TOLERANCE, "got {:.3}", lufs);
}
#[test]
fn single_channel_reads_3db_below_stereo() {
let mut samples = Vec::new();
append_sine(&mut samples, -23.0, 20.0, 48000);
let mut analyzer = Bs1770Analyzer::new(48000, 2);
for &s in &samples {
analyzer.add_frame(&[s, 0.0]);
}
let lufs = analyzer.into_blocks().integrated_lufs();
assert!((lufs - -26.01).abs() < TOLERANCE, "got {:.3}", lufs);
}
#[test]
fn silence_has_no_measurable_loudness() {
let samples = vec![0.0; 10 * 48000];
let lufs = integrated_stereo(&samples, 48000);
assert!(lufs.is_infinite() && lufs < 0.0);
}
#[test]
fn accumulate_merges_tracks_like_concatenation() {
let mut a = Vec::new();
append_sine(&mut a, -23.0, 20.0, 48000);
let mut b = Vec::new();
append_sine(&mut b, -33.0, 20.0, 48000);
let mut analyzer_a = Bs1770Analyzer::new(48000, 2);
for &s in &a {
analyzer_a.add_frame(&[s, s]);
}
let mut analyzer_b = Bs1770Analyzer::new(48000, 2);
for &s in &b {
analyzer_b.add_frame(&[s, s]);
}
let mut album = analyzer_a.into_blocks();
album.accumulate(&analyzer_b.into_blocks());
let expected = -23.0 + 10.0 * (1.1f64 / 2.0).log10();
let lufs = album.integrated_lufs();
assert!(
(lufs - expected).abs() < TOLERANCE,
"expected {:.3}, got {:.3}",
expected,
lufs
);
}
#[test]
fn odd_sample_rate_11025() {
let mut samples = Vec::new();
append_sine(&mut samples, -23.0, 20.0, 11025);
let lufs = integrated_stereo(&samples, 11025);
assert!((lufs - -23.0).abs() < 0.2, "got {:.3}", lufs);
}
}