Skip to main content

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