use std::path::{Path, PathBuf};
use std::time::Duration;
use ff_format::SampleFormat;
use crate::{AudioDecoder, DecodeError};
#[derive(Debug, Clone, PartialEq)]
pub struct WaveformSample {
pub timestamp: Duration,
pub peak_db: f32,
pub rms_db: f32,
}
pub struct WaveformAnalyzer {
input: PathBuf,
interval: Duration,
}
impl WaveformAnalyzer {
pub fn new(input: impl AsRef<Path>) -> Self {
Self {
input: input.as_ref().to_path_buf(),
interval: Duration::from_millis(100),
}
}
#[must_use]
pub fn interval(mut self, d: Duration) -> Self {
self.interval = d;
self
}
pub fn run(self) -> Result<Vec<WaveformSample>, DecodeError> {
if self.interval.is_zero() {
return Err(DecodeError::AnalysisFailed {
reason: "interval must be non-zero".to_string(),
});
}
let mut decoder = AudioDecoder::open(&self.input)
.output_format(SampleFormat::F32)
.build()?;
let mut results: Vec<WaveformSample> = Vec::new();
let mut interval_start = Duration::ZERO;
let mut bucket: Vec<f32> = Vec::new();
while let Some(frame) = decoder.decode_one()? {
let frame_start = frame.timestamp().as_duration();
while frame_start >= interval_start + self.interval {
if bucket.is_empty() {
results.push(WaveformSample {
timestamp: interval_start,
peak_db: f32::NEG_INFINITY,
rms_db: f32::NEG_INFINITY,
});
} else {
results.push(waveform_sample_from_bucket(interval_start, &bucket));
bucket.clear();
}
interval_start += self.interval;
}
if let Some(samples) = frame.as_f32() {
bucket.extend_from_slice(samples);
}
}
if !bucket.is_empty() {
results.push(waveform_sample_from_bucket(interval_start, &bucket));
}
log::debug!("waveform analysis complete samples={}", results.len());
Ok(results)
}
}
#[allow(clippy::cast_precision_loss)] pub(super) fn waveform_sample_from_bucket(timestamp: Duration, samples: &[f32]) -> WaveformSample {
let peak = samples
.iter()
.copied()
.map(f32::abs)
.fold(0.0_f32, f32::max);
let mean_sq = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
let rms = mean_sq.sqrt();
WaveformSample {
timestamp,
peak_db: amplitude_to_db(peak),
rms_db: amplitude_to_db(rms),
}
}
pub(super) fn amplitude_to_db(amplitude: f32) -> f32 {
if amplitude <= 0.0 {
f32::NEG_INFINITY
} else {
20.0 * amplitude.log10()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn amplitude_to_db_zero_should_be_neg_infinity() {
assert_eq!(amplitude_to_db(0.0), f32::NEG_INFINITY);
}
#[test]
fn amplitude_to_db_full_scale_should_be_zero_db() {
let db = amplitude_to_db(1.0);
assert!(
(db - 0.0).abs() < 1e-5,
"expected ~0 dBFS for full-scale amplitude, got {db}"
);
}
#[test]
fn amplitude_to_db_half_amplitude_should_be_about_minus_6db() {
let db = amplitude_to_db(0.5);
assert!(
(db - (-6.020_6)).abs() < 0.01,
"expected ~-6 dBFS for 0.5 amplitude, got {db}"
);
}
#[test]
fn waveform_analyzer_zero_interval_should_return_analysis_failed() {
let result = WaveformAnalyzer::new("irrelevant.mp3")
.interval(Duration::ZERO)
.run();
assert!(
matches!(result, Err(DecodeError::AnalysisFailed { .. })),
"expected AnalysisFailed, got {result:?}"
);
}
#[test]
fn waveform_analyzer_nonexistent_file_should_return_file_not_found() {
let result = WaveformAnalyzer::new("does_not_exist_12345.mp3").run();
assert!(
matches!(result, Err(DecodeError::FileNotFound { .. })),
"expected FileNotFound, got {result:?}"
);
}
#[test]
fn waveform_analyzer_silence_should_have_low_amplitude() {
let silent: Vec<f32> = vec![0.0; 4800];
let sample = waveform_sample_from_bucket(Duration::ZERO, &silent);
assert!(
sample.peak_db.is_infinite() && sample.peak_db.is_sign_negative(),
"expected -infinity peak_db for all-zero samples, got {}",
sample.peak_db
);
assert!(
sample.rms_db.is_infinite() && sample.rms_db.is_sign_negative(),
"expected -infinity rms_db for all-zero samples, got {}",
sample.rms_db
);
}
}