1use alpha2_media::{Speaker, SpeakerLayout};
21use libm::{log10, pow, tan};
22
23pub const ABSOLUTE_GATE_LUFS: f64 = -70.0;
25const INTEGRATED_RELATIVE_GATE: f64 = -10.0;
27const RANGE_RELATIVE_GATE: f64 = -20.0;
29const STEPS_PER_SECOND: u64 = 10;
31const MOMENTARY_STEPS: usize = 4;
33const SHORT_TERM_STEPS: usize = 30;
35const TRUE_PEAK_TAPS: usize = 12;
37const BINS_PER_LU: f64 = 100.0;
39const BINS: usize = 10_000;
41const FLUSH_BELOW: f64 = 1.0e-30;
43
44#[derive(Copy, Clone, Debug, Default, PartialEq)]
46pub struct LoudnessReport {
47 pub integrated_lufs: Option<f64>,
49 pub loudness_range_lu: Option<f64>,
51 pub true_peak_dbtp: Option<f64>,
53 pub sample_peak_dbfs: Option<f64>,
55 pub momentary_max_lufs: Option<f64>,
57 pub short_term_max_lufs: Option<f64>,
59 pub frames: u64,
61}
62
63#[derive(Copy, Clone, Debug, Eq, PartialEq)]
65#[non_exhaustive]
66pub enum LoudnessError {
67 InvalidShape,
69 MismatchedBlock,
71}
72
73impl std::fmt::Display for LoudnessError {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 formatter.write_str(match self {
76 Self::InvalidShape => "a loudness meter needs channels and a rate of 8 to 96 kHz",
77 Self::MismatchedBlock => "the block's channels do not match the meter's",
78 })
79 }
80}
81
82impl std::error::Error for LoudnessError {}
83
84#[derive(Clone, Debug)]
86struct Channel {
87 weight: f64,
88 shelf: Biquad64,
89 high_pass: Biquad64,
90 history: [f64; TRUE_PEAK_TAPS],
91}
92
93#[derive(Copy, Clone, Debug)]
95struct Biquad64 {
96 b: [f64; 3],
97 a: [f64; 2],
98 x: [f64; 2],
99 y: [f64; 2],
100}
101
102impl Biquad64 {
103 const fn new(b: [f64; 3], a: [f64; 2]) -> Self {
104 Self {
105 b,
106 a,
107 x: [0.0; 2],
108 y: [0.0; 2],
109 }
110 }
111
112 fn process(&mut self, input: f64) -> f64 {
113 let output = self.b[0] * input + self.b[1] * self.x[0] + self.b[2] * self.x[1]
114 - self.a[0] * self.y[0]
115 - self.a[1] * self.y[1];
116 let output = if output.abs() < FLUSH_BELOW {
118 0.0
119 } else {
120 output
121 };
122 self.x = [input, self.x[0]];
123 self.y = [output, self.y[0]];
124 output
125 }
126}
127
128fn shelf(sample_rate: u32) -> Biquad64 {
130 let f0 = 1_681.974_450_955_533;
131 let gain_db = 3.999_843_853_973_347;
132 let q = 0.707_175_236_955_419_6;
133 let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
134 let vh = pow(10.0, gain_db / 20.0);
135 let vb = pow(vh, 0.499_666_774_154_541_6);
136 let a0 = 1.0 + k / q + k * k;
137 Biquad64::new(
138 [
139 (vh + vb * k / q + k * k) / a0,
140 2.0 * (k * k - vh) / a0,
141 (vh - vb * k / q + k * k) / a0,
142 ],
143 [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
144 )
145}
146
147fn high_pass(sample_rate: u32) -> Biquad64 {
149 let f0 = 38.135_470_876_024_44;
150 let q = 0.500_327_037_323_877_3;
151 let k = tan(core::f64::consts::PI * f0 / f64::from(sample_rate));
152 let a0 = 1.0 + k / q + k * k;
153 Biquad64::new(
154 [1.0, -2.0, 1.0],
155 [2.0 * (k * k - 1.0) / a0, (1.0 - k / q + k * k) / a0],
156 )
157}
158
159fn lufs(power: f64) -> f64 {
161 -0.691 + 10.0 * log10(power)
162}
163
164fn decibels(amplitude: f64) -> f64 {
165 20.0 * log10(amplitude)
166}
167
168#[derive(Copy, Clone, Debug, Default)]
170struct Step {
171 energy: f64,
172 samples: u64,
173}
174
175#[derive(Copy, Clone, Debug, Default)]
177struct Bin {
178 count: u64,
179 power: f64,
180}
181
182#[derive(Clone, Debug)]
184struct Histogram {
185 bins: Box<[Bin]>,
186 count: u64,
187 power: f64,
188}
189
190impl Histogram {
191 fn new() -> Self {
192 Self {
193 bins: vec![Bin::default(); BINS].into_boxed_slice(),
194 count: 0,
195 power: 0.0,
196 }
197 }
198
199 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
201 fn add(&mut self, power: f64) {
202 if !(power.is_finite() && power > 0.0) {
203 return;
204 }
205 let loudness = lufs(power);
206 if loudness <= ABSOLUTE_GATE_LUFS {
207 return;
208 }
209 let index = (((loudness - ABSOLUTE_GATE_LUFS) * BINS_PER_LU) as usize).min(BINS - 1);
210 let bin = &mut self.bins[index];
211 bin.count += 1;
212 bin.power += power;
213 self.count += 1;
214 self.power += power;
215 }
216
217 #[allow(clippy::cast_precision_loss)]
220 fn relative_gate(&self, relative: f64) -> Option<f64> {
221 (self.count > 0)
222 .then(|| (lufs(self.power / self.count as f64) + relative).max(ABSOLUTE_GATE_LUFS))
223 }
224
225 #[allow(clippy::cast_precision_loss)]
227 fn over(&self, threshold: f64) -> impl Iterator<Item = &Bin> {
228 self.bins
229 .iter()
230 .filter(move |bin| bin.count > 0 && lufs(bin.power / bin.count as f64) > threshold)
231 }
232
233 #[allow(clippy::cast_precision_loss)]
236 fn gated_mean(&self, relative: f64) -> Option<f64> {
237 let threshold = self.relative_gate(relative)?;
238 let (power, count) = self
239 .over(threshold)
240 .fold((0.0, 0_u64), |(power, count), bin| {
241 (power + bin.power, count + bin.count)
242 });
243 (count > 0).then(|| power / count as f64)
244 }
245
246 #[allow(
249 clippy::cast_possible_truncation,
250 clippy::cast_precision_loss,
251 clippy::cast_sign_loss
252 )]
253 fn range(&self, relative: f64) -> Option<f64> {
254 let threshold = self.relative_gate(relative)?;
255 let count = self.over(threshold).map(|bin| bin.count).sum::<u64>();
256 let last = count.checked_sub(1)? as f64;
257 let at = |percentile: f64| {
258 let rank = (last * percentile + 0.5) as u64;
259 let mut below = 0;
260 self.over(threshold)
261 .find(|bin| {
262 below += bin.count;
263 below > rank
264 })
265 .map(|bin| lufs(bin.power / bin.count as f64))
266 };
267 Some(at(0.95)? - at(0.10)?)
268 }
269}
270
271#[derive(Clone, Debug)]
273pub struct LoudnessMeter {
274 sample_rate: u32,
275 channels: Vec<Channel>,
276 phases: Vec<[f64; TRUE_PEAK_TAPS]>,
278 open: Step,
279 step_index: u64,
280 recent: Vec<Step>,
282 blocks: Histogram,
284 short_terms: Histogram,
286 momentary_max: Option<f64>,
287 short_term_max: Option<f64>,
288 true_peak: f64,
289 sample_peak: f64,
290 frames: u64,
291}
292
293impl LoudnessMeter {
294 pub fn new(sample_rate: u32, channels: usize) -> Result<Self, LoudnessError> {
314 let layout = SpeakerLayout::default_for(channels).ok_or(LoudnessError::InvalidShape)?;
315 Self::for_speakers(sample_rate, layout.speakers())
316 }
317
318 pub fn for_speakers(sample_rate: u32, speakers: &[Speaker]) -> Result<Self, LoudnessError> {
329 const SURROUND: f64 = 1.41;
330 let has_sides = speakers
331 .iter()
332 .any(|speaker| matches!(speaker, Speaker::SideLeft | Speaker::SideRight));
333 let weights = speakers
334 .iter()
335 .map(|speaker| match speaker {
336 Speaker::LowFrequency | Speaker::LowFrequency2 => 0.0,
337 Speaker::SideLeft
338 | Speaker::SideRight
339 | Speaker::SideSurroundLeft
340 | Speaker::SideSurroundRight
341 | Speaker::SurroundDirectLeft
342 | Speaker::SurroundDirectRight
343 | Speaker::WideLeft
344 | Speaker::WideRight => SURROUND,
345 Speaker::BackLeft | Speaker::BackRight if !has_sides => SURROUND,
346 _ => 1.0,
347 })
348 .collect::<Vec<_>>();
349 Self::with_weights(sample_rate, &weights)
350 }
351
352 pub fn with_weights(sample_rate: u32, weights: &[f64]) -> Result<Self, LoudnessError> {
358 if weights.is_empty()
359 || weights
360 .iter()
361 .any(|weight| !weight.is_finite() || *weight < 0.0)
362 || !(8_000..=96_000).contains(&sample_rate)
363 {
364 return Err(LoudnessError::InvalidShape);
365 }
366 let factor = if sample_rate < 96_000 { 4 } else { 2 };
367 Ok(Self {
368 sample_rate,
369 channels: weights
370 .iter()
371 .map(|&weight| Channel {
372 weight,
373 shelf: shelf(sample_rate),
374 high_pass: high_pass(sample_rate),
375 history: [0.0; TRUE_PEAK_TAPS],
376 })
377 .collect(),
378 phases: interpolator(factor),
379 open: Step::default(),
380 step_index: 0,
381 recent: Vec::with_capacity(SHORT_TERM_STEPS),
382 blocks: Histogram::new(),
383 short_terms: Histogram::new(),
384 momentary_max: None,
385 short_term_max: None,
386 true_peak: 0.0,
387 sample_peak: 0.0,
388 frames: 0,
389 })
390 }
391
392 pub fn push(&mut self, planes: &[&[f32]]) -> Result<(), LoudnessError> {
397 let Some(frames) = planes.first().map(|plane| plane.len()) else {
398 return Err(LoudnessError::MismatchedBlock);
399 };
400 if planes.len() != self.channels.len() || planes.iter().any(|plane| plane.len() != frames) {
401 return Err(LoudnessError::MismatchedBlock);
402 }
403 for index in 0..frames {
404 let mut energy = 0.0;
405 for (channel, plane) in self.channels.iter_mut().zip(planes) {
406 let sample = plane[index];
409 let sample = if sample.is_finite() {
410 f64::from(sample)
411 } else {
412 0.0
413 };
414 let weighted = channel.high_pass.process(channel.shelf.process(sample));
415 energy += channel.weight * weighted * weighted;
416 self.sample_peak = self.sample_peak.max(sample.abs());
417 channel.history.copy_within(1.., 0);
418 channel.history[TRUE_PEAK_TAPS - 1] = sample;
419 for phase in &self.phases {
420 let interpolated = phase
421 .iter()
422 .zip(channel.history.iter().rev())
423 .fold(0.0, |sum, (tap, value)| sum + tap * value);
424 self.true_peak = self.true_peak.max(interpolated.abs());
425 }
426 }
427 self.open.energy += energy;
428 self.open.samples += 1;
429 self.frames += 1;
430 if self.frames == self.step_end() {
431 self.close_step();
432 }
433 }
434 Ok(())
435 }
436
437 fn step_end(&self) -> u64 {
439 (self.step_index + 1) * u64::from(self.sample_rate) / STEPS_PER_SECOND
440 }
441
442 fn close_step(&mut self) {
443 if self.recent.len() == SHORT_TERM_STEPS {
444 self.recent.remove(0);
445 }
446 self.recent.push(self.open);
447 self.open = Step::default();
448 self.step_index += 1;
449 if let Some(power) = self.window_power(MOMENTARY_STEPS) {
450 self.blocks.add(power);
451 let loudness = lufs(power);
452 self.momentary_max = Some(self.momentary_max.map_or(loudness, |max| max.max(loudness)));
453 }
454 if let Some(power) = self.window_power(SHORT_TERM_STEPS) {
455 self.short_terms.add(power);
456 let loudness = lufs(power);
457 self.short_term_max = Some(
458 self.short_term_max
459 .map_or(loudness, |max| max.max(loudness)),
460 );
461 }
462 }
463
464 #[allow(clippy::cast_precision_loss)]
466 fn window_power(&self, steps: usize) -> Option<f64> {
467 let window = self.recent.get(self.recent.len().checked_sub(steps)?..)?;
468 let (energy, samples) = window.iter().fold((0.0, 0_u64), |(energy, samples), step| {
469 (energy + step.energy, samples + step.samples)
470 });
471 (samples > 0).then(|| energy / samples as f64)
472 }
473
474 #[must_use]
476 pub fn momentary_lufs(&self) -> Option<f64> {
477 self.window_power(MOMENTARY_STEPS).map(lufs)
478 }
479
480 #[must_use]
482 pub fn short_term_lufs(&self) -> Option<f64> {
483 self.window_power(SHORT_TERM_STEPS).map(lufs)
484 }
485
486 #[must_use]
488 pub fn report(&self) -> LoudnessReport {
489 let peak = |amplitude: f64| (amplitude > 0.0).then(|| decibels(amplitude));
490 LoudnessReport {
491 integrated_lufs: self.blocks.gated_mean(INTEGRATED_RELATIVE_GATE).map(lufs),
492 loudness_range_lu: self.short_terms.range(RANGE_RELATIVE_GATE),
493 true_peak_dbtp: peak(self.true_peak.max(self.sample_peak)),
494 sample_peak_dbfs: peak(self.sample_peak),
495 momentary_max_lufs: self.momentary_max,
496 short_term_max_lufs: self.short_term_max,
497 frames: self.frames,
498 }
499 }
500}
501
502#[allow(clippy::cast_precision_loss)]
505fn interpolator(factor: usize) -> Vec<[f64; TRUE_PEAK_TAPS]> {
506 let length = factor * TRUE_PEAK_TAPS;
507 let centre = (length as f64 - 1.0) / 2.0;
508 let prototype = (0..length)
509 .map(|index| {
510 let offset = (index as f64 - centre) / factor as f64;
511 let sinc = if offset.abs() < 1e-12 {
512 1.0
513 } else {
514 let angle = core::f64::consts::PI * offset;
515 libm::sin(angle) / angle
516 };
517 let window = 0.5
518 - 0.5
519 * libm::cos(2.0 * core::f64::consts::PI * (index as f64 + 0.5) / length as f64);
520 sinc * window
521 })
522 .collect::<Vec<_>>();
523 (0..factor)
524 .map(|phase| {
525 let mut taps = [0.0; TRUE_PEAK_TAPS];
526 for (tap, value) in taps.iter_mut().enumerate() {
527 *value = prototype[phase + tap * factor];
528 }
529 let sum = taps.iter().sum::<f64>();
530 taps.map(|value| value / sum)
531 })
532 .collect()
533}
534
535pub const LIMITER_MARGIN_DB: f64 = 0.01;
538
539#[derive(Copy, Clone, Debug, PartialEq)]
541pub struct LoudnessNormalization {
542 pub gain_db: f64,
544 pub integrated_lufs: f64,
546 pub true_peak_dbtp: f64,
548 pub peak_limited: bool,
550 pub limiter_limited: bool,
552}
553
554impl LoudnessNormalization {
555 #[must_use]
561 pub fn plan(
562 report: &LoudnessReport,
563 target_lufs: f64,
564 ceiling_dbtp: f64,
565 limiter_threshold: Option<f64>,
566 ) -> Option<Self> {
567 let integrated = report.integrated_lufs.filter(|value| value.is_finite())?;
568 if report.true_peak_dbtp.is_some_and(|peak| !peak.is_finite())
569 || report
570 .sample_peak_dbfs
571 .is_some_and(|peak| !peak.is_finite())
572 || limiter_threshold
573 .is_some_and(|threshold| !(threshold.is_finite() && threshold > 0.0))
574 {
575 return None;
576 }
577 let to_target = target_lufs - integrated;
578 let to_ceiling = report
579 .true_peak_dbtp
580 .map_or(f64::INFINITY, |peak| ceiling_dbtp - peak);
581 let to_limiter = match (limiter_threshold, report.sample_peak_dbfs) {
582 (Some(threshold), Some(peak)) => {
583 20.0 * libm::log10(threshold) - LIMITER_MARGIN_DB - peak
584 }
585 _ => f64::INFINITY,
586 };
587 let gain_db = to_target.min(to_ceiling).min(to_limiter);
588 Some(Self {
589 gain_db,
590 integrated_lufs: integrated + gain_db,
591 true_peak_dbtp: report
592 .true_peak_dbtp
593 .map_or(f64::NEG_INFINITY, |peak| peak + gain_db),
594 peak_limited: gain_db < to_target,
595 limiter_limited: to_limiter < to_target.min(to_ceiling),
596 })
597 }
598
599 #[must_use]
601 pub fn linear_gain(&self) -> f64 {
602 pow(10.0, self.gain_db / 20.0)
603 }
604}
605
606#[cfg(test)]
607#[path = "loudness_tests.rs"]
608mod tests;