1use alpha2_media::{Speaker, SpeakerLayout};
26use libm::{log10, pow, tan};
27
28pub const ABSOLUTE_GATE_LUFS: f64 = -70.0;
30const INTEGRATED_RELATIVE_GATE: f64 = -10.0;
32const RANGE_RELATIVE_GATE: f64 = -20.0;
34const STEPS_PER_SECOND: u64 = 10;
36const MOMENTARY_STEPS: usize = 4;
38const SHORT_TERM_STEPS: usize = 30;
40const TRUE_PEAK_TAPS: usize = 12;
42const BINS_PER_LU: f64 = 100.0;
44const BINS: usize = 10_000;
47const FLUSH_BELOW: f64 = 1.0e-30;
50
51#[derive(Copy, Clone, Debug, Default, PartialEq)]
53pub struct LoudnessReport {
54 pub integrated_lufs: Option<f64>,
57 pub loudness_range_lu: Option<f64>,
59 pub true_peak_dbtp: Option<f64>,
61 pub sample_peak_dbfs: Option<f64>,
63 pub momentary_max_lufs: Option<f64>,
65 pub short_term_max_lufs: Option<f64>,
67 pub frames: u64,
69}
70
71#[derive(Copy, Clone, Debug, Eq, PartialEq)]
73#[non_exhaustive]
74pub enum LoudnessError {
75 InvalidShape,
77 MismatchedBlock,
79}
80
81impl std::fmt::Display for LoudnessError {
82 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 formatter.write_str(match self {
84 Self::InvalidShape => "a loudness meter needs channels and a rate of 8 to 96 kHz",
85 Self::MismatchedBlock => "the block's channels do not match the meter's",
86 })
87 }
88}
89
90impl std::error::Error for LoudnessError {}
91
92#[derive(Clone, Debug)]
94struct Channel {
95 weight: f64,
96 shelf: Biquad64,
97 high_pass: Biquad64,
98 history: [f64; TRUE_PEAK_TAPS],
100}
101
102#[derive(Copy, Clone, Debug)]
104struct Biquad64 {
105 b: [f64; 3],
106 a: [f64; 2],
107 x: [f64; 2],
108 y: [f64; 2],
109}
110
111impl Biquad64 {
112 const fn new(b: [f64; 3], a: [f64; 2]) -> Self {
113 Self {
114 b,
115 a,
116 x: [0.0; 2],
117 y: [0.0; 2],
118 }
119 }
120
121 fn process(&mut self, input: f64) -> f64 {
122 let output = self.b[0] * input + self.b[1] * self.x[0] + self.b[2] * self.x[1]
123 - self.a[0] * self.y[0]
124 - self.a[1] * self.y[1];
125 let output = if output.abs() < FLUSH_BELOW {
128 0.0
129 } else {
130 output
131 };
132 self.x = [input, self.x[0]];
133 self.y = [output, self.y[0]];
134 output
135 }
136}
137
138fn shelf(sample_rate: u32) -> Biquad64 {
141 let f0 = 1_681.974_450_955_533;
142 let gain_db = 3.999_843_853_973_347;
143 let q = 0.707_175_236_955_419_6;
144 let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
145 let vh = pow(10.0, gain_db / 20.0);
146 let vb = pow(vh, 0.499_666_774_154_541_6);
147 let a0 = 1.0 + k / q + k * k;
148 Biquad64::new(
149 [
150 (vh + vb * k / q + k * k) / a0,
151 2.0 * (k * k - vh) / a0,
152 (vh - vb * k / q + k * k) / a0,
153 ],
154 [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
155 )
156}
157
158fn high_pass(sample_rate: u32) -> Biquad64 {
161 let f0 = 38.135_470_876_024_44;
162 let q = 0.500_327_037_323_877_3;
163 let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
164 let a0 = 1.0 + k / q + k * k;
165 Biquad64::new(
166 [1.0, -2.0, 1.0],
167 [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
168 )
169}
170
171fn lufs(power: f64) -> f64 {
173 -0.691 + 10.0 * log10(power)
174}
175
176fn decibels(amplitude: f64) -> f64 {
178 20.0 * log10(amplitude)
179}
180
181#[derive(Copy, Clone, Debug, Default)]
183struct Step {
184 energy: f64,
185 samples: u64,
186}
187
188#[derive(Copy, Clone, Debug, Default)]
190struct Bin {
191 count: u64,
192 power: f64,
193}
194
195#[derive(Clone, Debug)]
198struct Histogram {
199 bins: Box<[Bin]>,
200 count: u64,
201 power: f64,
202}
203
204impl Histogram {
205 fn new() -> Self {
206 Self {
207 bins: vec![Bin::default(); BINS].into_boxed_slice(),
208 count: 0,
209 power: 0.0,
210 }
211 }
212
213 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
217 fn add(&mut self, power: f64) {
218 if !(power.is_finite() && power > 0.0) {
219 return;
220 }
221 let loudness = lufs(power);
222 if loudness <= ABSOLUTE_GATE_LUFS {
223 return;
224 }
225 let index = (((loudness - ABSOLUTE_GATE_LUFS) * BINS_PER_LU) as usize).min(BINS - 1);
226 let bin = &mut self.bins[index];
227 bin.count += 1;
228 bin.power += power;
229 self.count += 1;
230 self.power += power;
231 }
232
233 #[allow(clippy::cast_precision_loss)]
237 fn relative_gate(&self, relative: f64) -> Option<f64> {
238 (self.count > 0)
239 .then(|| (lufs(self.power / self.count as f64) + relative).max(ABSOLUTE_GATE_LUFS))
240 }
241
242 #[allow(clippy::cast_precision_loss)]
245 fn over(&self, threshold: f64) -> impl Iterator<Item = &Bin> {
246 self.bins
247 .iter()
248 .filter(move |bin| bin.count > 0 && lufs(bin.power / bin.count as f64) > threshold)
249 }
250
251 #[allow(clippy::cast_precision_loss)]
254 fn gated_mean(&self, relative: f64) -> Option<f64> {
255 let threshold = self.relative_gate(relative)?;
256 let (power, count) = self
257 .over(threshold)
258 .fold((0.0, 0_u64), |(power, count), bin| {
259 (power + bin.power, count + bin.count)
260 });
261 (count > 0).then(|| power / count as f64)
262 }
263
264 #[allow(
268 clippy::cast_possible_truncation,
269 clippy::cast_precision_loss,
270 clippy::cast_sign_loss
271 )]
272 fn range(&self, relative: f64) -> Option<f64> {
273 let threshold = self.relative_gate(relative)?;
274 let count = self.over(threshold).map(|bin| bin.count).sum::<u64>();
275 let last = count.checked_sub(1)? as f64;
276 let at = |percentile: f64| {
277 let rank = (last * percentile + 0.5) as u64;
278 let mut below = 0;
279 self.over(threshold)
280 .find(|bin| {
281 below += bin.count;
282 below > rank
283 })
284 .map(|bin| lufs(bin.power / bin.count as f64))
285 };
286 Some(at(0.95)? - at(0.10)?)
287 }
288}
289
290#[derive(Clone, Debug)]
292pub struct LoudnessMeter {
293 sample_rate: u32,
294 channels: Vec<Channel>,
295 phases: Vec<[f64; TRUE_PEAK_TAPS]>,
297 open: Step,
298 step_index: u64,
300 recent: Vec<Step>,
302 blocks: Histogram,
304 short_terms: Histogram,
306 momentary_max: Option<f64>,
307 short_term_max: Option<f64>,
308 true_peak: f64,
309 sample_peak: f64,
310 frames: u64,
311}
312
313impl LoudnessMeter {
314 pub fn new(sample_rate: u32, channels: usize) -> Result<Self, LoudnessError> {
338 let layout = SpeakerLayout::default_for(channels).ok_or(LoudnessError::InvalidShape)?;
339 Self::for_speakers(sample_rate, layout.speakers())
340 }
341
342 pub fn for_speakers(sample_rate: u32, speakers: &[Speaker]) -> Result<Self, LoudnessError> {
358 const SURROUND: f64 = 1.41;
359 let has_sides = speakers
360 .iter()
361 .any(|speaker| matches!(speaker, Speaker::SideLeft | Speaker::SideRight));
362 let weights = speakers
363 .iter()
364 .map(|speaker| match speaker {
365 Speaker::LowFrequency | Speaker::LowFrequency2 => 0.0,
366 Speaker::SideLeft
367 | Speaker::SideRight
368 | Speaker::SideSurroundLeft
369 | Speaker::SideSurroundRight
370 | Speaker::SurroundDirectLeft
371 | Speaker::SurroundDirectRight
372 | Speaker::WideLeft
373 | Speaker::WideRight => SURROUND,
374 Speaker::BackLeft | Speaker::BackRight if !has_sides => SURROUND,
375 _ => 1.0,
376 })
377 .collect::<Vec<_>>();
378 Self::with_weights(sample_rate, &weights)
379 }
380
381 pub fn with_weights(sample_rate: u32, weights: &[f64]) -> Result<Self, LoudnessError> {
387 if weights.is_empty()
388 || weights
389 .iter()
390 .any(|weight| !weight.is_finite() || *weight < 0.0)
391 || !(8_000..=96_000).contains(&sample_rate)
392 {
393 return Err(LoudnessError::InvalidShape);
394 }
395 let factor = if sample_rate < 96_000 { 4 } else { 2 };
396 Ok(Self {
397 sample_rate,
398 channels: weights
399 .iter()
400 .map(|&weight| Channel {
401 weight,
402 shelf: shelf(sample_rate),
403 high_pass: high_pass(sample_rate),
404 history: [0.0; TRUE_PEAK_TAPS],
405 })
406 .collect(),
407 phases: interpolator(factor),
408 open: Step::default(),
409 step_index: 0,
410 recent: Vec::with_capacity(SHORT_TERM_STEPS),
411 blocks: Histogram::new(),
412 short_terms: Histogram::new(),
413 momentary_max: None,
414 short_term_max: None,
415 true_peak: 0.0,
416 sample_peak: 0.0,
417 frames: 0,
418 })
419 }
420
421 pub fn push(&mut self, planes: &[&[f32]]) -> Result<(), LoudnessError> {
428 let Some(frames) = planes.first().map(|plane| plane.len()) else {
429 return Err(LoudnessError::MismatchedBlock);
430 };
431 if planes.len() != self.channels.len() || planes.iter().any(|plane| plane.len() != frames) {
432 return Err(LoudnessError::MismatchedBlock);
433 }
434 for index in 0..frames {
435 let mut energy = 0.0;
436 for (channel, plane) in self.channels.iter_mut().zip(planes) {
437 let sample = plane[index];
440 let sample = if sample.is_finite() {
441 f64::from(sample)
442 } else {
443 0.0
444 };
445 let weighted = channel.high_pass.process(channel.shelf.process(sample));
446 energy += channel.weight * weighted * weighted;
447 self.sample_peak = self.sample_peak.max(sample.abs());
448 channel.history.copy_within(1.., 0);
449 channel.history[TRUE_PEAK_TAPS - 1] = sample;
450 for phase in &self.phases {
451 let interpolated = phase
452 .iter()
453 .zip(channel.history.iter().rev())
454 .fold(0.0, |sum, (tap, value)| sum + tap * value);
455 self.true_peak = self.true_peak.max(interpolated.abs());
456 }
457 }
458 self.open.energy += energy;
459 self.open.samples += 1;
460 self.frames += 1;
461 if self.frames == self.step_end() {
462 self.close_step();
463 }
464 }
465 Ok(())
466 }
467
468 fn step_end(&self) -> u64 {
471 (self.step_index + 1) * u64::from(self.sample_rate) / STEPS_PER_SECOND
472 }
473
474 fn close_step(&mut self) {
475 if self.recent.len() == SHORT_TERM_STEPS {
476 self.recent.remove(0);
477 }
478 self.recent.push(self.open);
479 self.open = Step::default();
480 self.step_index += 1;
481 if let Some(power) = self.window_power(MOMENTARY_STEPS) {
482 self.blocks.add(power);
483 let loudness = lufs(power);
484 self.momentary_max = Some(self.momentary_max.map_or(loudness, |max| max.max(loudness)));
485 }
486 if let Some(power) = self.window_power(SHORT_TERM_STEPS) {
487 self.short_terms.add(power);
488 let loudness = lufs(power);
489 self.short_term_max = Some(
490 self.short_term_max
491 .map_or(loudness, |max| max.max(loudness)),
492 );
493 }
494 }
495
496 #[allow(clippy::cast_precision_loss)]
499 fn window_power(&self, steps: usize) -> Option<f64> {
500 let window = self.recent.get(self.recent.len().checked_sub(steps)?..)?;
501 let (energy, samples) = window.iter().fold((0.0, 0_u64), |(energy, samples), step| {
502 (energy + step.energy, samples + step.samples)
503 });
504 (samples > 0).then(|| energy / samples as f64)
505 }
506
507 #[must_use]
509 pub fn momentary_lufs(&self) -> Option<f64> {
510 self.window_power(MOMENTARY_STEPS).map(lufs)
511 }
512
513 #[must_use]
515 pub fn short_term_lufs(&self) -> Option<f64> {
516 self.window_power(SHORT_TERM_STEPS).map(lufs)
517 }
518
519 #[must_use]
521 pub fn report(&self) -> LoudnessReport {
522 let peak = |amplitude: f64| (amplitude > 0.0).then(|| decibels(amplitude));
523 LoudnessReport {
524 integrated_lufs: self.blocks.gated_mean(INTEGRATED_RELATIVE_GATE).map(lufs),
525 loudness_range_lu: self.short_terms.range(RANGE_RELATIVE_GATE),
526 true_peak_dbtp: peak(self.true_peak.max(self.sample_peak)),
527 sample_peak_dbfs: peak(self.sample_peak),
528 momentary_max_lufs: self.momentary_max,
529 short_term_max_lufs: self.short_term_max,
530 frames: self.frames,
531 }
532 }
533}
534
535#[allow(clippy::cast_precision_loss)]
539fn interpolator(factor: usize) -> Vec<[f64; TRUE_PEAK_TAPS]> {
540 let length = factor * TRUE_PEAK_TAPS;
541 let centre = (length as f64 - 1.0) / 2.0;
542 let prototype = (0..length)
543 .map(|index| {
544 let offset = (index as f64 - centre) / factor as f64;
545 let sinc = if offset.abs() < 1e-12 {
546 1.0
547 } else {
548 let angle = core::f64::consts::PI * offset;
549 libm::sin(angle) / angle
550 };
551 let window = 0.5
552 - 0.5
553 * libm::cos(2.0 * core::f64::consts::PI * (index as f64 + 0.5) / length as f64);
554 sinc * window
555 })
556 .collect::<Vec<_>>();
557 (0..factor)
558 .map(|phase| {
559 let mut taps = [0.0; TRUE_PEAK_TAPS];
560 for (tap, value) in taps.iter_mut().enumerate() {
561 *value = prototype[phase + tap * factor];
562 }
563 let sum = taps.iter().sum::<f64>();
564 taps.map(|value| value / sum)
565 })
566 .collect()
567}
568
569pub const LIMITER_MARGIN_DB: f64 = 0.01;
575
576#[derive(Copy, Clone, Debug, PartialEq)]
580pub struct LoudnessNormalization {
581 pub gain_db: f64,
583 pub integrated_lufs: f64,
585 pub true_peak_dbtp: f64,
587 pub peak_limited: bool,
591 pub limiter_limited: bool,
594}
595
596impl LoudnessNormalization {
597 #[must_use]
610 pub fn plan(
611 report: &LoudnessReport,
612 target_lufs: f64,
613 ceiling_dbtp: f64,
614 limiter_threshold: Option<f64>,
615 ) -> Option<Self> {
616 let integrated = report.integrated_lufs.filter(|value| value.is_finite())?;
617 if report.true_peak_dbtp.is_some_and(|peak| !peak.is_finite())
618 || report
619 .sample_peak_dbfs
620 .is_some_and(|peak| !peak.is_finite())
621 || limiter_threshold
622 .is_some_and(|threshold| !(threshold.is_finite() && threshold > 0.0))
623 {
624 return None;
625 }
626 let to_target = target_lufs - integrated;
627 let to_ceiling = report
628 .true_peak_dbtp
629 .map_or(f64::INFINITY, |peak| ceiling_dbtp - peak);
630 let to_limiter = match (limiter_threshold, report.sample_peak_dbfs) {
631 (Some(threshold), Some(peak)) => {
632 20.0 * libm::log10(threshold) - LIMITER_MARGIN_DB - peak
633 }
634 _ => f64::INFINITY,
635 };
636 let gain_db = to_target.min(to_ceiling).min(to_limiter);
637 Some(Self {
638 gain_db,
639 integrated_lufs: integrated + gain_db,
640 true_peak_dbtp: report
641 .true_peak_dbtp
642 .map_or(f64::NEG_INFINITY, |peak| peak + gain_db),
643 peak_limited: gain_db < to_target,
644 limiter_limited: to_limiter < to_target.min(to_ceiling),
645 })
646 }
647
648 #[must_use]
650 pub fn linear_gain(&self) -> f64 {
651 pow(10.0, self.gain_db / 20.0)
652 }
653}
654
655#[cfg(test)]
656#[path = "loudness_tests.rs"]
657mod tests;