sva_samples/measure/
loudness.rs1use crate::biquad::{Coeffs, State};
4
5const SHELF_HZ: f64 = 1681.97;
8const SHELF_Q: f64 = 0.70718;
9const SHELF_GAIN_DB: f64 = 3.99984;
10const SHELF_MID: f64 = 0.499_666_774_154_541_6;
11const HIGHPASS_HZ: f64 = 38.1355;
12const HIGHPASS_Q: f64 = 0.50033;
13
14fn shelf(sr: f64) -> Coeffs {
15 let k = (std::f64::consts::PI * SHELF_HZ / sr).tan();
16 let high = 10f64.powf(SHELF_GAIN_DB / 20.0);
17 let mid = high.powf(SHELF_MID);
18 let a0 = 1.0 + k / SHELF_Q + k * k;
19 Coeffs {
20 b0: (high + mid * k / SHELF_Q + k * k) / a0,
21 b1: 2.0 * (k * k - high) / a0,
22 b2: (high - mid * k / SHELF_Q + k * k) / a0,
23 a1: 2.0 * (k * k - 1.0) / a0,
24 a2: (1.0 - k / SHELF_Q + k * k) / a0,
25 }
26}
27
28fn highpass(sr: f64) -> Coeffs {
29 let k = (std::f64::consts::PI * HIGHPASS_HZ / sr).tan();
30 let a0 = 1.0 + k / HIGHPASS_Q + k * k;
31 Coeffs {
32 b0: 1.0,
33 b1: -2.0,
34 b2: 1.0,
35 a1: 2.0 * (k * k - 1.0) / a0,
36 a2: (1.0 - k / HIGHPASS_Q + k * k) / a0,
37 }
38}
39
40const OFFSET_DB: f64 = -0.691;
41const ABSOLUTE_GATE: f64 = -70.0;
42const INTEGRATED_GATE_LU: f64 = -10.0;
43
44const RANGE_GATE_LU: f64 = -20.0;
45
46const MOMENTARY_SECS: f64 = 0.4;
47const SHORT_TERM_SECS: f64 = 3.0;
48
49const STEP_SECS: f64 = 0.1;
50
51const PEAK_NOTE: &str = "sample peak, not true peak: no oversampling exists here, so an inter-sample peak above \
52 this figure is not measured";
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct LoudnessFrame {
56 pub t: f64,
58 pub lufs: f64,
59}
60
61#[derive(Clone, Debug, PartialEq)]
62pub struct Loudness {
63 pub integrated_lufs: Option<f64>,
64 pub range_lu: Option<f64>,
65 pub momentary_max_lufs: Option<f64>,
66 pub short_term_max_lufs: Option<f64>,
67 pub sample_peak: f64,
68 pub sample_peak_dbfs: Option<f64>,
69 pub peak_note: &'static str,
70 pub momentary: Vec<LoudnessFrame>,
71 pub short_term: Vec<LoudnessFrame>,
72}
73
74pub fn analyze(planes: &[&[f64]], sr: f64, start_secs: f64) -> Loudness {
77 let squares: Vec<Vec<f64>> = planes.iter().map(|p| running_squares(p, sr)).collect();
78 let samples = planes.first().map_or(0, |p| p.len());
79
80 let momentary = blocks(&squares, samples, sr, start_secs, MOMENTARY_SECS);
81 let short_term = blocks(&squares, samples, sr, start_secs, SHORT_TERM_SECS);
82 let sample_peak = planes
83 .iter()
84 .flat_map(|p| p.iter())
85 .fold(0.0f64, |acc, v| acc.max(v.abs()));
86
87 Loudness {
88 integrated_lufs: gated_mean(&momentary, INTEGRATED_GATE_LU),
89 range_lu: range(&short_term),
90 momentary_max_lufs: peak_of(&momentary),
91 short_term_max_lufs: peak_of(&short_term),
92 sample_peak,
93 sample_peak_dbfs: (sample_peak > 0.0).then(|| 20.0 * sample_peak.log10()),
94 peak_note: PEAK_NOTE,
95 momentary,
96 short_term,
97 }
98}
99
100fn running_squares(plane: &[f64], sr: f64) -> Vec<f64> {
102 let shelf = shelf(sr);
103 let cut = highpass(sr);
104 let mut first = State::default();
105 let mut second = State::default();
106 let mut out = Vec::with_capacity(plane.len() + 1);
107 let mut total = 0.0;
108 out.push(0.0);
109 for x in plane {
110 let k = second.step(&cut, first.step(&shelf, *x));
111 total += k * k;
112 out.push(total);
113 }
114 out
115}
116
117fn blocks(
118 squares: &[Vec<f64>],
119 samples: usize,
120 sr: f64,
121 start_secs: f64,
122 block_secs: f64,
123) -> Vec<LoudnessFrame> {
124 let n = (block_secs * sr).round() as usize;
125 let step = ((STEP_SECS * sr).round() as usize).max(1);
126 if n == 0 || samples < n {
127 return Vec::new();
128 }
129 (0..=(samples - n))
130 .step_by(step)
131 .map(|at| LoudnessFrame {
132 t: start_secs + at as f64 / sr,
133 lufs: level(squares, at, n),
134 })
135 .collect()
136}
137
138fn level(squares: &[Vec<f64>], at: usize, n: usize) -> f64 {
139 let power: f64 = squares.iter().map(|s| (s[at + n] - s[at]) / n as f64).sum();
140 if power <= 0.0 {
141 return f64::NEG_INFINITY;
142 }
143 OFFSET_DB + 10.0 * power.log10()
144}
145
146fn mean_above(frames: &[LoudnessFrame], threshold: f64) -> Option<f64> {
148 let kept: Vec<f64> = frames
149 .iter()
150 .map(|f| f.lufs)
151 .filter(|l| *l > threshold)
152 .collect();
153 if kept.is_empty() {
154 return None;
155 }
156 let power: f64 = kept
157 .iter()
158 .map(|l| 10f64.powf((l - OFFSET_DB) / 10.0))
159 .sum();
160 Some(OFFSET_DB + 10.0 * (power / kept.len() as f64).log10())
161}
162
163fn gated_mean(frames: &[LoudnessFrame], relative_lu: f64) -> Option<f64> {
165 mean_above(frames, mean_above(frames, ABSOLUTE_GATE)? + relative_lu)
166}
167
168fn range(short_term: &[LoudnessFrame]) -> Option<f64> {
170 let floor = mean_above(short_term, ABSOLUTE_GATE)? + RANGE_GATE_LU;
171 let mut kept: Vec<f64> = short_term
172 .iter()
173 .map(|f| f.lufs)
174 .filter(|l| *l > ABSOLUTE_GATE && *l > floor)
175 .collect();
176 if kept.is_empty() {
177 return None;
178 }
179 kept.sort_by(f64::total_cmp);
180 Some(percentile(&kept, 0.95) - percentile(&kept, 0.10))
181}
182
183fn percentile(sorted: &[f64], p: f64) -> f64 {
184 let at = ((sorted.len() as f64 - 1.0) * p).round() as usize;
185 sorted[at.min(sorted.len() - 1)]
186}
187
188fn peak_of(frames: &[LoudnessFrame]) -> Option<f64> {
189 frames
190 .iter()
191 .map(|f| f.lufs)
192 .filter(|l| l.is_finite())
193 .reduce(f64::max)
194}