Skip to main content

audiobox/
analyze.rs

1//! Waveforms, spectral analysis and K-weighted integrated loudness.
2mod measurements;
3use crate::{Audio, Error, Result};
4pub use measurements::{
5    BoundaryMeasurement, BoundarySamples, ChannelStep, MAX_MEASUREMENT_BOUNDARIES,
6    MeasurementBoundary, StereoMeasurements, ThresholdSilence, dc_mean, stereo_measurements,
7};
8use std::f64::consts::PI;
9
10pub fn to_db(linear: f64) -> f64 {
11    if linear > 0.0 {
12        20.0 * linear.log10()
13    } else {
14        f64::NEG_INFINITY
15    }
16}
17pub fn from_db(db: f64) -> f64 {
18    crate::math::db_to_gain(db)
19}
20// Render decisions must not depend on the host libm. Report-only APIs retain
21// their original math; normalization uses the same scans with deterministic math.
22pub(crate) fn render_db(linear: f64) -> f64 {
23    if linear > 0.0 {
24        20.0 * crate::math::log2(linear) / std::f64::consts::LOG2_10
25    } else {
26        f64::NEG_INFINITY
27    }
28}
29#[derive(Clone, Debug)]
30pub struct LoudnessResult {
31    /// Integrated K-weighted level in LUFS; negative infinity if unavailable.
32    pub integrated: f64,
33    /// Legacy gated 400-ms block percentile range in LU; zero if unavailable.
34    pub range: f64,
35    /// Maximum 400-ms block level in LUFS, or the whole-input short fallback.
36    pub peak_momentary: f64,
37}
38/// Non-mutating deterministic measurements of the supplied PCM, without gain or
39/// quantization. These unrounded f64 values may drive repeatable delivery checks.
40/// Numerical accuracy is separate from cross-host identity; see
41/// [`Audio::measure_deterministic`] for the estimator and no-result policies.
42#[derive(Clone, Debug)]
43pub struct DeterministicMeasurements {
44    /// K-weighted loudness in LUFS, with a legacy block-percentile range in LU.
45    pub loudness: LoudnessResult,
46    /// Maximum absolute sample in dBFS; negative infinity for empty/silent PCM.
47    pub peak_db: f64,
48    /// Four-times reconstructed peak estimate in dBTP; negative infinity for silence.
49    pub true_peak_db: f64,
50}
51#[derive(Clone, Debug)]
52pub struct Waveform {
53    pub min: Vec<f32>,
54    pub max: Vec<f32>,
55}
56#[derive(Clone, Debug)]
57/// One-sided Hann-windowed tone-amplitude spectrum; not a power spectral density
58/// or calibrated band RMS measurement. Uses platform math, with report-only
59/// numerical accuracy rather than an exact rendering guarantee.
60pub struct Spectrum {
61    /// Bin centers in Hz: `k * sample_rate / fft_size`, including DC and Nyquist.
62    pub frequencies: Vec<f64>,
63    /// Linear amplitudes in input-sample units, normalized by the full Hann sum.
64    /// Interior bins are doubled; DC and Nyquist are not. See [`spectrum`].
65    pub magnitudes: Vec<f64>,
66}
67#[derive(Clone, Debug)]
68pub struct Spectrogram {
69    pub frequencies: Vec<f64>,
70    pub times: Vec<f64>,
71    pub magnitudes: Vec<Vec<f64>>,
72}
73
74fn fft(real: &mut [f64], imag: &mut [f64]) {
75    let n = real.len();
76    let mut j = 0;
77    for i in 1..n {
78        let mut bit = n >> 1;
79        while j & bit != 0 {
80            j ^= bit;
81            bit >>= 1;
82        }
83        j ^= bit;
84        if i < j {
85            real.swap(i, j);
86            imag.swap(i, j);
87        }
88    }
89    let mut len = 2;
90    while len <= n {
91        let theta = -2.0 * PI / len as f64;
92        let (wi, wr) = theta.sin_cos();
93        for start in (0..n).step_by(len) {
94            let (mut ur, mut ui) = (1.0, 0.0);
95            for k in 0..len / 2 {
96                let a = start + k;
97                let b = a + len / 2;
98                let tr = ur * real[b] - ui * imag[b];
99                let ti = ur * imag[b] + ui * real[b];
100                real[b] = real[a] - tr;
101                imag[b] = imag[a] - ti;
102                real[a] += tr;
103                imag[a] += ti;
104                let next = ur * wr - ui * wi;
105                ui = ur * wi + ui * wr;
106                ur = next;
107            }
108        }
109        len *= 2;
110    }
111}
112fn check_fft(sample_rate: u32, fft_size: usize) -> Result<()> {
113    if sample_rate == 0 || fft_size < 2 || !fft_size.is_power_of_two() || fft_size > 1 << 20 {
114        return Err(Error::invalid(
115            "FFT size must be a power of two from 2 to 1048576",
116        ));
117    }
118    Ok(())
119}
120fn check_finite(samples: &[f32]) -> Result<()> {
121    if samples.iter().any(|s| !s.is_finite()) {
122        return Err(Error::invalid("FFT samples must be finite"));
123    }
124    Ok(())
125}
126/// The Hann window of `fft_size` points and the sum of its values, which scales magnitudes.
127fn hann(fft_size: usize) -> (Vec<f64>, f64) {
128    let mut sum = 0.0;
129    let window = (0..fft_size)
130        .map(|i| {
131            let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / (fft_size - 1) as f64).cos();
132            sum += w;
133            w
134        })
135        .collect();
136    (window, sum)
137}
138/// Windows the first `real.len()` samples, zero-padded, transforms them in the given
139/// buffers and returns the one-sided magnitudes.
140fn transform(
141    samples: &[f32],
142    window: &[f64],
143    sum: f64,
144    real: &mut [f64],
145    imag: &mut [f64],
146) -> Vec<f64> {
147    let fft_size = real.len();
148    for (i, (v, w)) in real.iter_mut().zip(window).enumerate() {
149        *v = samples.get(i).copied().unwrap_or(0.0) as f64 * w;
150    }
151    imag.fill(0.0);
152    fft(real, imag);
153    (0..=fft_size / 2)
154        .map(|i| {
155            real[i].hypot(imag[i]) / sum
156                * if i == 0 || i == fft_size / 2 {
157                    1.0
158                } else {
159                    2.0
160                }
161        })
162        .collect()
163}
164fn frequencies(sample_rate: u32, fft_size: usize) -> Vec<f64> {
165    (0..=fft_size / 2)
166        .map(|i| i as f64 * sample_rate as f64 / fft_size as f64)
167        .collect()
168}
169/// One-sided tone-amplitude magnitudes of the first `fft_size` samples.
170///
171/// Applies the symmetric Hann `0.5 - 0.5*cos(2*pi*i/(fft_size-1))`, then divides
172/// complex magnitudes by the **entire** window sum and doubles interior bins.
173/// DC and Nyquist are included without doubling. Bin spacing is
174/// `sample_rate / fft_size` Hz. These are linear input-sample amplitude units,
175/// not calibrated band RMS, power or PSD; squared-bin shares are descriptive.
176///
177/// Extra input is truncated; short input is right-zero-padded without changing
178/// the window or normalization. Its samples occupy the window's original
179/// positions. A sole sample at index zero therefore produces zero magnitudes,
180/// even if nonzero. FFT size two has an all-zero Hann and returns NaN magnitudes
181/// (zero divided by zero), preserving the existing result. Do not interpret an
182/// absent windowed signal as proof of silence in the original PCM.
183///
184/// `sample_rate` must be nonzero; `fft_size` must be a power of two in
185/// 2..=1048576. Every input sample must be finite, including truncated samples.
186/// Invalid arguments return [`crate::ErrorCode::InvalidArgument`]. Work is
187/// O(samples.len() + fft_size * log2(fft_size)), with O(fft_size) storage.
188/// Platform FFT/window math is report-only, not cross-host bit identity.
189pub fn spectrum(samples: &[f32], sample_rate: u32, fft_size: usize) -> Result<Spectrum> {
190    check_fft(sample_rate, fft_size)?;
191    check_finite(samples)?;
192    let (window, sum) = hann(fft_size);
193    let mut real = vec![0.0; fft_size];
194    let mut imag = vec![0.0; fft_size];
195    Ok(Spectrum {
196        frequencies: frequencies(sample_rate, fft_size),
197        magnitudes: transform(samples, &window, sum, &mut real, &mut imag),
198    })
199}
200/// Spectra of `samples` every `hop` samples, each as [`spectrum`] of the samples from
201/// that point on. Every sample must be finite.
202pub fn spectrogram(samples: &[f32], rate: u32, fft_size: usize, hop: usize) -> Result<Spectrogram> {
203    if hop == 0 {
204        return Err(Error::invalid("Spectrogram hop must be positive"));
205    }
206    check_fft(rate, fft_size)?;
207    let columns = samples.len().div_ceil(hop);
208    if columns
209        .checked_mul(fft_size / 2 + 1)
210        .is_none_or(|n| n > 64 * 1024 * 1024)
211    {
212        return Err(Error::limit("Spectrogram exceeds allocation limit"));
213    }
214    // Each column's suffix is part of the whole input: checking it once gives the
215    // same result as checking every suffix, without rescanning the input per column.
216    check_finite(samples)?;
217    // The window and transform buffers serve every column; only the output grows.
218    let (window, sum) = hann(fft_size);
219    let mut real = vec![0.0; fft_size];
220    let mut imag = vec![0.0; fft_size];
221    let mut times = Vec::with_capacity(columns);
222    let mut magnitudes = Vec::with_capacity(columns);
223    for i in (0..samples.len()).step_by(hop) {
224        times.push(i as f64 / rate as f64);
225        magnitudes.push(transform(&samples[i..], &window, sum, &mut real, &mut imag));
226    }
227    Ok(Spectrogram {
228        frequencies: frequencies(rate, fft_size),
229        times,
230        magnitudes,
231    })
232}
233impl Audio {
234    pub fn peak(&self) -> f64 {
235        self.data
236            .iter()
237            .flatten()
238            .map(|s| s.abs() as f64)
239            .fold(0.0, f64::max)
240    }
241    pub fn peak_db(&self) -> f64 {
242        to_db(self.peak())
243    }
244    /// Mean squared sample amplitude, averaged equally over **all** channels and
245    /// frames: sum(sample^2) / (frames * channels). Empty and silent PCM return
246    /// zero. Finite samples outside +/-1 retain their power without clamping.
247    /// Accumulation is f64 in planar channel/frame order, O(samples) with no
248    /// allocation. This is unweighted signal power, not calibrated loudness.
249    /// For aggregation use `frames() * channels()` as the sample denominator;
250    /// combining regions uses the weighted mean of their powers.
251    pub fn mean_square(&self) -> f64 {
252        let n = self.frames() * self.channels();
253        if n == 0 {
254            0.0
255        } else {
256            self.data
257                .iter()
258                .flatten()
259                .map(|&s| (s as f64).powi(2))
260                .sum::<f64>()
261                / n as f64
262        }
263    }
264    /// Linear root-mean-square sample amplitude, `sqrt(mean_square())`.
265    /// Empty/silent PCM returns zero; channels have equal weight. This retains
266    /// the report-only numerical policy of [`Self::rms_db`].
267    pub fn rms(&self) -> f64 {
268        self.mean_square().sqrt()
269    }
270    /// Unweighted RMS in dBFS; negative infinity for empty/silent PCM.
271    pub fn rms_db(&self) -> f64 {
272        to_db(self.rms())
273    }
274    /// Measure this exact PCM with the deterministic normalization estimators.
275    /// No gain, clipping, resampling, quantization or PCM clone is performed.
276    ///
277    /// Uses unrounded f64 levels and deterministic coefficient/logarithm math.
278    /// True peak is the existing 4x/32-tap-per-phase Blackman sinc estimate,
279    /// including sample peak, with zero extension and scans at input frames.
280    /// It is not a universal continuous-time bound or the limiter's 8x detector.
281    ///
282    /// Loudness uses full 400-ms blocks at 100-ms hops, inclusive -70 LUFS and
283    /// relative -10 LU gates. A shorter input uses its entire ungated power.
284    /// Empty, silent or wholly gated input has integrated/peak-momentary levels
285    /// of negative infinity and range zero. Quad weights are 1/1/1.41/1.41,
286    /// five-channel 1/1/1/1.41/1.41, six-channel 1/1/1/0/1.41/1.41; other
287    /// counts use unit weights. Channel count does not infer a speaker layout.
288    /// The range is a legacy block-percentile estimate, not certified EBU LRA.
289    ///
290    /// Requires >=4000 Hz, finite input and finite filter results; violations
291    /// return `InvalidArgument`, including on empty input. Checks default
292    /// [`crate::Limits`] before workspace allocation (also for Audio decoded
293    /// with custom limits); a larger shape returns `LimitExceeded`. Offline
294    /// work/storage is proportional to samples and block count, as in
295    /// normalization. Existing report-only analysis keeps its platform math.
296    pub fn measure_deterministic(&self) -> Result<DeterministicMeasurements> {
297        if self.rate < 4000 {
298            return Err(Error::invalid(
299                "Deterministic loudness requires at least 4000 Hz",
300            ));
301        }
302        crate::Limits::default().check(self.frames(), self.channels(), self.rate)?;
303        if self.data.iter().flatten().any(|s| !s.is_finite()) {
304            return Err(Error::invalid("Measurement samples must be finite"));
305        }
306        let loudness = self.render_loudness();
307        if !loudness.range.is_finite()
308            || [loudness.integrated, loudness.peak_momentary]
309                .iter()
310                .any(|v| !v.is_finite() && *v != f64::NEG_INFINITY)
311        {
312            return Err(Error::invalid("Deterministic measurement overflow"));
313        }
314        Ok(DeterministicMeasurements {
315            loudness,
316            peak_db: render_db(self.peak()),
317            true_peak_db: self.render_true_peak_db(),
318        })
319    }
320    pub fn true_peak_db(&self) -> f64 {
321        to_db(self.true_peak::<false>())
322    }
323    pub(crate) fn render_true_peak_db(&self) -> f64 {
324        render_db(self.render_true_peak())
325    }
326    pub(crate) fn render_true_peak(&self) -> f64 {
327        self.true_peak::<true>()
328    }
329    fn true_peak<const RENDER: bool>(&self) -> f64 {
330        let mut peak = self.peak();
331        let mut kernel = [0.0; 128];
332        for (i, k) in kernel.iter_mut().enumerate() {
333            let t = i as f64 / 4.0 - 16.0;
334            let sinc = if t == 0.0 {
335                1.0
336            } else {
337                (if RENDER {
338                    crate::math::sin_turns(t / 2.0)
339                } else {
340                    (PI * t).sin()
341                }) / (PI * t)
342            };
343            let w = i as f64 / 127.0;
344            let cos = |turns| {
345                if RENDER {
346                    crate::math::cos_turns(turns)
347                } else {
348                    (2.0 * PI * turns).cos()
349                }
350            };
351            *k = sinc * (0.42 - 0.5 * cos(w) + 0.08 * cos(2.0 * w));
352        }
353        for c in &self.data {
354            for i in 0..c.len() {
355                // Away from the edges every tap is in range: the four phases then add the
356                // same terms in the same order without bounds checks, side by side.
357                if i >= 16 && i + 16 <= c.len() {
358                    let mut sums = [0.0f64; 4];
359                    for (k, &x) in c[i - 16..i + 16].iter().enumerate() {
360                        for (phase, sum) in sums.iter_mut().enumerate() {
361                            *sum += x as f64 * kernel[k * 4 + phase];
362                        }
363                    }
364                    for sum in sums {
365                        peak = peak.max(sum.abs());
366                    }
367                    continue;
368                }
369                for phase in 0..4 {
370                    let mut sum = 0.0;
371                    for k in 0..32 {
372                        let at = i as i64 + k as i64 - 16;
373                        if at >= 0 && (at as usize) < c.len() {
374                            sum += c[at as usize] as f64 * kernel[k * 4 + phase];
375                        }
376                    }
377                    peak = peak.max(sum.abs());
378                }
379            }
380        }
381        peak
382    }
383    pub fn waveform(&self, buckets: usize) -> Result<Waveform> {
384        if buckets == 0 || buckets > 16 * 1024 * 1024 {
385            return Err(Error::invalid("Invalid waveform bucket count"));
386        }
387        let mut min = vec![0.0f32; buckets];
388        let mut max = vec![0.0f32; buckets];
389        for b in 0..buckets {
390            let start = (b as u128 * self.frames() as u128 / buckets as u128) as usize;
391            let end = (((b + 1) as u128 * self.frames() as u128 / buckets as u128) as usize)
392                .max(start.saturating_add(1))
393                .min(self.frames());
394            for c in &self.data {
395                for &s in &c[start..end] {
396                    min[b] = min[b].min(s);
397                    max[b] = max[b].max(s);
398                }
399            }
400        }
401        Ok(Waveform { min, max })
402    }
403    /// K-weighted, gated loudness; range is the legacy 400-ms block percentile estimate.
404    pub fn loudness(&self) -> LoudnessResult {
405        self.loudness_impl::<false>()
406    }
407    pub(crate) fn render_loudness(&self) -> LoudnessResult {
408        self.loudness_impl::<true>()
409    }
410    fn loudness_impl<const RENDER: bool>(&self) -> LoudnessResult {
411        let rate = self.rate as f64;
412        let tangent = |frequency: f64| {
413            if RENDER {
414                let turns = frequency / rate / 2.0;
415                crate::math::sin_turns(turns) / crate::math::cos_turns(turns)
416            } else {
417                (PI * frequency / rate).tan()
418            }
419        };
420        let k = tangent(1681.974450955533);
421        let vh = if RENDER {
422            crate::math::db_to_gain(3.999843853973347)
423        } else {
424            10f64.powf(3.999843853973347 / 20.0)
425        };
426        let vb = if RENDER {
427            crate::math::db_to_gain(3.999843853973347 * 0.4996667741545416)
428        } else {
429            vh.powf(0.4996667741545416)
430        };
431        let q = 0.7071752369554196;
432        let a0 = 1.0 + k / q + k * k;
433        let shelf = [
434            (vh + vb * k / q + k * k) / a0,
435            2.0 * (k * k - vh) / a0,
436            (vh - vb * k / q + k * k) / a0,
437            2.0 * (k * k - 1.0) / a0,
438            (1.0 - k / q + k * k) / a0,
439        ];
440        let k = tangent(38.13547087602444);
441        let q = 0.5003270373238773;
442        let a0 = 1.0 + k / q + k * k;
443        let hp = [
444            1.0,
445            -2.0,
446            1.0,
447            2.0 * (k * k - 1.0) / a0,
448            (1.0 - k / q + k * k) / a0,
449        ];
450        let filtered: Vec<_> = self
451            .data
452            .iter()
453            .map(|c| crate::dsp::biquad(&crate::dsp::biquad(c, shelf), hp))
454            .collect();
455        if RENDER && filtered.iter().flatten().any(|s| !s.is_finite()) {
456            return LoudnessResult {
457                integrated: f64::NAN,
458                range: f64::NAN,
459                peak_momentary: f64::NAN,
460            };
461        }
462        let weights: Vec<f64> = match self.channels() {
463            4 => vec![1.0, 1.0, 1.41, 1.41],
464            5 => vec![1.0, 1.0, 1.0, 1.41, 1.41],
465            6 => vec![1.0, 1.0, 1.0, 0.0, 1.41, 1.41],
466            _ => vec![1.0; self.channels()],
467        };
468        let block = (0.4 * rate).round().max(1.0) as usize;
469        let step = (0.1 * rate).round().max(1.0) as usize;
470        let power = |start: usize, end: usize| {
471            if start == end {
472                return 0.0;
473            }
474            filtered
475                .iter()
476                .zip(&weights)
477                .map(|(c, w)| {
478                    w * c[start..end]
479                        .iter()
480                        .map(|&s| (s as f64).powi(2))
481                        .sum::<f64>()
482                        / (end - start) as f64
483                })
484                .sum::<f64>()
485        };
486        let lufs = |p: f64| {
487            if p > 0.0 {
488                -0.691
489                    + if RENDER {
490                        10.0 * crate::math::log2(p) / std::f64::consts::LOG2_10
491                    } else {
492                        10.0 * p.log10()
493                    }
494            } else {
495                f64::NEG_INFINITY
496            }
497        };
498        if self.frames() < block {
499            let value = lufs(power(0, self.frames()));
500            return LoudnessResult {
501                integrated: value,
502                range: 0.0,
503                peak_momentary: value,
504            };
505        }
506        let blocks: Vec<_> = (0..=self.frames() - block)
507            .step_by(step)
508            .map(|i| power(i, i + block))
509            .collect();
510        gated_loudness(&blocks, lufs)
511    }
512}
513
514fn gated_loudness(blocks: &[f64], lufs: impl Fn(f64) -> f64) -> LoudnessResult {
515    let absolute: Vec<_> = blocks
516        .iter()
517        .copied()
518        .filter(|&p| lufs(p) >= -70.0)
519        .collect();
520    if absolute.is_empty() {
521        return LoudnessResult {
522            integrated: f64::NEG_INFINITY,
523            range: 0.0,
524            peak_momentary: f64::NEG_INFINITY,
525        };
526    }
527    let relative = lufs(absolute.iter().sum::<f64>() / absolute.len() as f64) - 10.0;
528    let gated: Vec<_> = absolute
529        .iter()
530        .copied()
531        .filter(|&p| lufs(p) >= relative)
532        .collect();
533    let integrated = lufs(gated.iter().sum::<f64>() / gated.len() as f64);
534    let mut sorted: Vec<_> = gated.iter().map(|&p| lufs(p)).collect();
535    sorted.sort_by(f64::total_cmp);
536    let range = sorted[(sorted.len() * 95 / 100).min(sorted.len() - 1)] - sorted[sorted.len() / 10];
537    LoudnessResult {
538        integrated,
539        range,
540        peak_momentary: blocks
541            .iter()
542            .map(|&p| lufs(p))
543            .fold(f64::NEG_INFINITY, f64::max),
544    }
545}
546
547#[cfg(test)]
548mod normalization_tests {
549    use super::*;
550
551    #[test]
552    fn gates_include_boundary_levels_and_separate_adjacent_powers() {
553        let lufs = |p: f64| -0.691 + render_db(p) / 2.0;
554        // Find the adjacent f64 powers bracketing -70 LUFS, without assuming
555        // that converting -70 to power and back is an exact inverse.
556        let mut bits = crate::math::db_to_gain((-70.0 + 0.691) * 2.0).to_bits();
557        while lufs(f64::from_bits(bits)) < -70.0 {
558            bits += 1;
559        }
560        while lufs(f64::from_bits(bits - 1)) >= -70.0 {
561            bits -= 1;
562        }
563        let below = f64::from_bits(bits - 1);
564        let above = f64::from_bits(bits);
565        assert_eq!(gated_loudness(&[below], lufs).integrated, f64::NEG_INFINITY);
566        assert_eq!(gated_loudness(&[above], lufs).integrated, lufs(above));
567        // Equality must be admitted even when the level is exactly the gate.
568        assert_eq!(gated_loudness(&[1.0], |_| -70.0).integrated, -70.0);
569        // Two-block relative gate: q >= mean(1,q)/10 iff q >= 1/19.
570        let below = (1.0 / 19.0) * (1.0 - 1e-10);
571        let above = (1.0 / 19.0) * (1.0 + 1e-10);
572        assert_eq!(gated_loudness(&[1.0, below], lufs).integrated, lufs(1.0));
573        assert_eq!(
574            gated_loudness(&[1.0, above], lufs).integrated,
575            lufs((1.0 + above) / 2.0)
576        );
577        let equality = |p: f64| {
578            if p == 1.0 {
579                -20.0
580            } else if p == 0.1 {
581                -30.0
582            } else {
583                -20.0
584            }
585        };
586        assert_eq!(gated_loudness(&[1.0, 0.1], equality).range, 10.0);
587    }
588}