Skip to main content

ff_filter/analysis/
loudness_meter.rs

1//! EBU R128 loudness measurement.
2
3#![allow(unsafe_code)]
4
5use std::path::{Path, PathBuf};
6
7use crate::FilterError;
8
9/// Result of an EBU R128 loudness measurement.
10///
11/// Values are computed by `FFmpeg`'s `ebur128` filter over the entire
12/// duration of the input file.
13#[derive(Debug, Clone, PartialEq)]
14pub struct LoudnessResult {
15    /// Integrated loudness in LUFS (ITU-R BS.1770-4).
16    ///
17    /// [`f32::NEG_INFINITY`] when the audio is silence or the measurement
18    /// could not be computed.
19    pub integrated_lufs: f32,
20    /// Loudness range (LRA) in LU.
21    pub lra: f32,
22    /// True peak in dBTP.
23    ///
24    /// [`f32::NEG_INFINITY`] when the measurement could not be computed.
25    pub true_peak_dbtp: f32,
26}
27
28/// Measures EBU R128 integrated loudness, loudness range, and true peak.
29///
30/// Uses `FFmpeg`'s `ebur128=metadata=1:peak=true` filter graph internally.
31/// The analysis is self-contained — no external decoder is required.
32///
33/// # Examples
34///
35/// ```ignore
36/// use ff_filter::LoudnessMeter;
37///
38/// let result = LoudnessMeter::new("audio.mp3").measure()?;
39/// println!("Integrated: {:.1} LUFS", result.integrated_lufs);
40/// println!("LRA: {:.1} LU", result.lra);
41/// println!("True peak: {:.1} dBTP", result.true_peak_dbtp);
42/// ```
43pub struct LoudnessMeter {
44    input: PathBuf,
45}
46
47impl LoudnessMeter {
48    /// Creates a new meter for the given audio or video file.
49    pub fn new(input: impl AsRef<Path>) -> Self {
50        Self {
51            input: input.as_ref().to_path_buf(),
52        }
53    }
54
55    /// Runs EBU R128 loudness analysis and returns the result.
56    ///
57    /// # Errors
58    ///
59    /// - [`FilterError::AnalysisFailed`] — input file not found, unsupported
60    ///   format, or the filter graph could not be constructed.
61    pub fn measure(self) -> Result<LoudnessResult, FilterError> {
62        if !self.input.exists() {
63            return Err(FilterError::AnalysisFailed {
64                reason: format!("file not found: {}", self.input.display()),
65            });
66        }
67        // SAFETY: measure_loudness_unsafe manages all raw pointer lifetimes
68        // according to the avfilter ownership rules: the graph is allocated with
69        // avfilter_graph_alloc(), built and configured, drained, then freed before
70        // returning.  The path CString is valid for the duration of the graph build.
71        unsafe { super::analysis_inner::measure_loudness_unsafe(&self.input) }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn loudness_meter_missing_file_should_return_analysis_failed() {
81        let result = LoudnessMeter::new("does_not_exist_99999.mp3").measure();
82        assert!(
83            matches!(result, Err(FilterError::AnalysisFailed { .. })),
84            "expected AnalysisFailed for missing file, got {result:?}"
85        );
86    }
87}