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}