ff_decode/analysis/
waveform_analyzer.rs1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::SampleFormat;
7
8use crate::{AudioDecoder, DecodeError};
9
10#[derive(Debug, Clone, PartialEq)]
16pub struct WaveformSample {
17 pub timestamp: Duration,
19 pub peak_db: f32,
22 pub rms_db: f32,
25}
26
27pub struct WaveformAnalyzer {
50 input: PathBuf,
51 interval: Duration,
52}
53
54impl WaveformAnalyzer {
55 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 #[must_use]
74 pub fn interval(mut self, d: Duration) -> Self {
75 self.interval = d;
76 self
77 }
78
79 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 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 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#[allow(clippy::cast_precision_loss)] pub(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
158pub(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}