Skip to main content

ff_analysis/analysis/
waveform_analyzer.rs

1//! Waveform amplitude analysis for audio files.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::SampleFormat;
7
8use ff_decode::AudioDecoder;
9
10use crate::AnalysisError;
11
12/// A single waveform measurement over a configurable time interval.
13///
14/// Both amplitude values are expressed in dBFS (decibels relative to full
15/// scale). `0.0` dBFS means the signal reached maximum amplitude; values
16/// approach [`f32::NEG_INFINITY`] for silence.
17#[derive(Debug, Clone, PartialEq)]
18pub struct WaveformSample {
19    /// Start of the time interval this sample covers.
20    pub timestamp: Duration,
21    /// Peak amplitude in dBFS (`max(|s|)` over all samples in the interval).
22    /// [`f32::NEG_INFINITY`] when the interval contains only silence.
23    pub peak_db: f32,
24    /// RMS amplitude in dBFS (`sqrt(mean(s²))` over all samples).
25    /// [`f32::NEG_INFINITY`] when the interval contains only silence.
26    pub rms_db: f32,
27}
28
29/// Computes peak and RMS amplitude per time interval for an audio file.
30///
31/// Decodes audio via [`AudioDecoder`] (requesting packed `f32` output so that
32/// per-sample arithmetic needs no format dispatch) and computes, for each
33/// configurable interval, the peak and RMS amplitudes in dBFS.  The resulting
34/// [`Vec<WaveformSample>`] is designed for waveform display rendering.
35///
36/// # Examples
37///
38/// ```ignore
39/// use ff_analysis::WaveformAnalyzer;
40/// use std::time::Duration;
41///
42/// let samples = WaveformAnalyzer::new("audio.mp3")
43///     .interval(Duration::from_millis(50))
44///     .run()?;
45///
46/// for s in &samples {
47///     println!("{:?}: peak={:.1} dBFS  rms={:.1} dBFS",
48///              s.timestamp, s.peak_db, s.rms_db);
49/// }
50/// ```
51pub struct WaveformAnalyzer {
52    input: PathBuf,
53    interval: Duration,
54}
55
56impl WaveformAnalyzer {
57    /// Creates a new analyzer for the given audio file.
58    ///
59    /// The default sampling interval is 100 ms.  Call
60    /// [`interval`](Self::interval) to override it.
61    pub fn new(input: impl AsRef<Path>) -> Self {
62        Self {
63            input: input.as_ref().to_path_buf(),
64            interval: Duration::from_millis(100),
65        }
66    }
67
68    /// Sets the sampling interval.
69    ///
70    /// Peak and RMS are computed independently for each interval of this
71    /// length.  Passing [`Duration::ZERO`] causes [`run`](Self::run) to
72    /// return [`AnalysisError::Failed`].
73    ///
74    /// Default: 100 ms.
75    #[must_use]
76    pub fn interval(mut self, d: Duration) -> Self {
77        self.interval = d;
78        self
79    }
80
81    /// Runs the waveform analysis and returns one [`WaveformSample`] per interval.
82    ///
83    /// The timestamp of each sample is the **start** of its interval.  Audio
84    /// is decoded as packed `f32` samples; the decoder performs any necessary
85    /// format conversion automatically.
86    ///
87    /// # Errors
88    ///
89    /// - [`AnalysisError::Failed`] — interval is [`Duration::ZERO`].
90    /// - [`ff_decode::DecodeError::FileNotFound`] — input path does not exist.
91    /// - Any other [`ff_decode::DecodeError`] propagated from [`AudioDecoder`],
92    ///   wrapped in [`AnalysisError::Decode`].
93    pub fn run(self) -> Result<Vec<WaveformSample>, AnalysisError> {
94        if self.interval.is_zero() {
95            return Err(AnalysisError::Failed {
96                reason: "interval must be non-zero".to_string(),
97            });
98        }
99
100        let mut decoder = AudioDecoder::open(&self.input)
101            .output_format(SampleFormat::F32)
102            .build()?;
103
104        let mut results: Vec<WaveformSample> = Vec::new();
105        let mut interval_start = Duration::ZERO;
106        let mut bucket: Vec<f32> = Vec::new();
107
108        while let Some(frame) = decoder.decode_one()? {
109            let frame_start = frame.timestamp().as_duration();
110
111            // Flush all completed intervals that end before this frame begins.
112            while frame_start >= interval_start + self.interval {
113                if bucket.is_empty() {
114                    results.push(WaveformSample {
115                        timestamp: interval_start,
116                        peak_db: f32::NEG_INFINITY,
117                        rms_db: f32::NEG_INFINITY,
118                    });
119                } else {
120                    results.push(waveform_sample_from_bucket(interval_start, &bucket));
121                    bucket.clear();
122                }
123                interval_start += self.interval;
124            }
125
126            if let Some(samples) = frame.as_f32() {
127                bucket.extend_from_slice(samples);
128            }
129        }
130
131        // Flush the final partial interval.
132        if !bucket.is_empty() {
133            results.push(waveform_sample_from_bucket(interval_start, &bucket));
134        }
135
136        log::debug!("waveform analysis complete samples={}", results.len());
137        Ok(results)
138    }
139}
140
141/// Builds a [`WaveformSample`] from the raw `f32` PCM values accumulated for
142/// one interval.
143#[allow(clippy::cast_precision_loss)] // sample count fits comfortably in f32
144pub(super) fn waveform_sample_from_bucket(timestamp: Duration, samples: &[f32]) -> WaveformSample {
145    let peak = samples
146        .iter()
147        .copied()
148        .map(f32::abs)
149        .fold(0.0_f32, f32::max);
150
151    let mean_sq = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
152    let rms = mean_sq.sqrt();
153
154    WaveformSample {
155        timestamp,
156        peak_db: amplitude_to_db(peak),
157        rms_db: amplitude_to_db(rms),
158    }
159}
160
161/// Converts a linear amplitude (0.0–1.0) to dBFS.
162///
163/// Zero and negative amplitudes map to [`f32::NEG_INFINITY`].
164pub(super) fn amplitude_to_db(amplitude: f32) -> f32 {
165    if amplitude <= 0.0 {
166        f32::NEG_INFINITY
167    } else {
168        20.0 * amplitude.log10()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use ff_decode::DecodeError;
175
176    use super::*;
177
178    #[test]
179    fn amplitude_to_db_zero_should_be_neg_infinity() {
180        assert_eq!(amplitude_to_db(0.0), f32::NEG_INFINITY);
181    }
182
183    #[test]
184    fn amplitude_to_db_full_scale_should_be_zero_db() {
185        let db = amplitude_to_db(1.0);
186        assert!(
187            (db - 0.0).abs() < 1e-5,
188            "expected ~0 dBFS for full-scale amplitude, got {db}"
189        );
190    }
191
192    #[test]
193    fn amplitude_to_db_half_amplitude_should_be_about_minus_6db() {
194        let db = amplitude_to_db(0.5);
195        assert!(
196            (db - (-6.020_6)).abs() < 0.01,
197            "expected ~-6 dBFS for 0.5 amplitude, got {db}"
198        );
199    }
200
201    #[test]
202    fn waveform_analyzer_zero_interval_should_return_analysis_failed() {
203        let result = WaveformAnalyzer::new("irrelevant.mp3")
204            .interval(Duration::ZERO)
205            .run();
206        assert!(
207            matches!(result, Err(AnalysisError::Failed { .. })),
208            "expected Failed, got {result:?}"
209        );
210    }
211
212    #[test]
213    fn waveform_analyzer_nonexistent_file_should_return_file_not_found() {
214        let result = WaveformAnalyzer::new("does_not_exist_12345.mp3").run();
215        assert!(
216            matches!(
217                result,
218                Err(AnalysisError::Decode(DecodeError::FileNotFound { .. }))
219            ),
220            "expected Decode(FileNotFound), got {result:?}"
221        );
222    }
223
224    #[test]
225    fn waveform_analyzer_silence_should_have_low_amplitude() {
226        let silent: Vec<f32> = vec![0.0; 4800];
227        let sample = waveform_sample_from_bucket(Duration::ZERO, &silent);
228        assert!(
229            sample.peak_db.is_infinite() && sample.peak_db.is_sign_negative(),
230            "expected -infinity peak_db for all-zero samples, got {}",
231            sample.peak_db
232        );
233        assert!(
234            sample.rms_db.is_infinite() && sample.rms_db.is_sign_negative(),
235            "expected -infinity rms_db for all-zero samples, got {}",
236            sample.rms_db
237        );
238    }
239}