ff_analysis/analysis/
waveform_analyzer.rs1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use ff_format::SampleFormat;
7
8use ff_decode::AudioDecoder;
9
10use crate::AnalysisError;
11
12#[derive(Debug, Clone, PartialEq)]
18pub struct WaveformSample {
19 pub timestamp: Duration,
21 pub peak_db: f32,
24 pub rms_db: f32,
27}
28
29pub struct WaveformAnalyzer {
52 input: PathBuf,
53 interval: Duration,
54}
55
56impl WaveformAnalyzer {
57 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 #[must_use]
76 pub fn interval(mut self, d: Duration) -> Self {
77 self.interval = d;
78 self
79 }
80
81 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 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 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#[allow(clippy::cast_precision_loss)] pub(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
161pub(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}