#[cfg(feature = "replaygain")]
use anyhow::Context;
use anyhow::Result;
use std::path::Path;
#[cfg(feature = "replaygain")]
use crate::mp4meta;
#[cfg(feature = "replaygain")]
use symphonia::core::audio::{AudioBufferRef, Signal};
#[cfg(feature = "replaygain")]
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
#[cfg(feature = "replaygain")]
use symphonia::core::formats::FormatOptions;
#[cfg(feature = "replaygain")]
use symphonia::core::io::MediaSourceStream;
#[cfg(feature = "replaygain")]
use symphonia::core::meta::MetadataOptions;
#[cfg(feature = "replaygain")]
use symphonia::core::probe::Hint;
pub const REPLAYGAIN_REFERENCE_DB: f64 = 89.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AudioFileType {
Mp3,
Aac,
}
#[derive(Debug, Clone)]
pub struct ReplayGainResult {
pub loudness_db: f64,
pub gain_db: f64,
pub peak: f64,
pub sample_rate: u32,
pub file_type: AudioFileType,
}
impl ReplayGainResult {
pub fn gain_steps(&self) -> i32 {
(self.gain_db / crate::GAIN_STEP_DB).round() as i32
}
}
#[derive(Debug, Clone)]
pub struct AlbumGainResult {
pub tracks: Vec<ReplayGainResult>,
pub album_loudness_db: f64,
pub album_gain_db: f64,
pub album_peak: f64,
}
impl AlbumGainResult {
pub fn album_gain_steps(&self) -> i32 {
(self.album_gain_db / crate::GAIN_STEP_DB).round() as i32
}
}
#[cfg(feature = "replaygain")]
mod filter_coeffs {
pub const YULE_A_44100: [f64; 11] = [
1.0,
-3.84664617118067,
7.81501653005538,
-11.34170355132042,
13.05504219327545,
-12.28759895145294,
9.48293806319790,
-5.87257861775999,
2.75465861874613,
-0.86984376593551,
0.13919314567432,
];
pub const YULE_B_44100: [f64; 11] = [
0.05418656406430,
-0.02911007808948,
-0.00848709379851,
-0.00851165645469,
-0.00834990904936,
0.02245293253339,
-0.02596338512915,
0.01624864962975,
-0.00240879051584,
0.00674613682247,
-0.00187763777362,
];
pub const BUTTER_A_44100: [f64; 3] = [1.0, -1.96977855582618, 0.97022847566350];
pub const BUTTER_B_44100: [f64; 3] = [0.98500175787242, -1.97000351574484, 0.98500175787242];
pub const YULE_A_48000: [f64; 11] = [
1.0,
-3.47845948550071,
6.36317777566148,
-8.54751527471874,
9.47693607801280,
-8.81498681370155,
6.85401540936998,
-4.39470996079559,
2.19611684890774,
-0.75104302451432,
0.13149317958808,
];
pub const YULE_B_48000: [f64; 11] = [
0.03857599435200,
-0.02160367184185,
-0.00123395316851,
-0.00009291677959,
-0.01655260341619,
0.02161526843274,
-0.02074045215285,
0.00594298065125,
0.00306428023191,
0.00012025322027,
0.00288463683916,
];
pub const BUTTER_A_48000: [f64; 3] = [1.0, -1.97223372919527, 0.97261396931306];
pub const BUTTER_B_48000: [f64; 3] = [0.98621192462708, -1.97242384925416, 0.98621192462708];
pub const YULE_A_32000: [f64; 11] = [
1.0,
-2.37898834973084,
2.84868151156327,
-2.64577170229825,
2.23697657451713,
-1.67148153367602,
1.00595954808547,
-0.45953458054983,
0.16378164858596,
-0.05032077717131,
0.02347897407020,
];
pub const YULE_B_32000: [f64; 11] = [
0.00549836071843,
-0.00528297328296,
-0.00426998268581,
-0.00180414805164,
-0.00032550931093,
0.00252831508428,
-0.00331474531993,
0.00311096798626,
-0.00166102790290,
0.00042903502747,
0.00023777076452,
];
pub const BUTTER_A_32000: [f64; 3] = [1.0, -1.95466019695138, 0.95531569668911];
pub const BUTTER_B_32000: [f64; 3] = [0.97743085512243, -1.95486171024486, 0.97743085512243];
}
#[cfg(feature = "replaygain")]
struct EqualLoudnessFilter {
yule_a: [f64; 11],
yule_b: [f64; 11],
butter_a: [f64; 3],
butter_b: [f64; 3],
yule_x: [f64; 11],
yule_y: [f64; 11],
butter_x: [f64; 3],
butter_y: [f64; 3],
}
#[cfg(feature = "replaygain")]
impl EqualLoudnessFilter {
fn new(sample_rate: u32) -> Self {
use filter_coeffs::*;
let (yule_a, yule_b, butter_a, butter_b) = match sample_rate {
48000 => (YULE_A_48000, YULE_B_48000, BUTTER_A_48000, BUTTER_B_48000),
32000 => (YULE_A_32000, YULE_B_32000, BUTTER_A_32000, BUTTER_B_32000),
_ => (YULE_A_44100, YULE_B_44100, BUTTER_A_44100, BUTTER_B_44100), };
Self {
yule_a,
yule_b,
butter_a,
butter_b,
yule_x: [0.0; 11],
yule_y: [0.0; 11],
butter_x: [0.0; 3],
butter_y: [0.0; 3],
}
}
fn process(&mut self, sample: f64) -> f64 {
for i in (1..11).rev() {
self.yule_x[i] = self.yule_x[i - 1];
self.yule_y[i] = self.yule_y[i - 1];
}
self.yule_x[0] = sample;
let mut yule_out = self.yule_b[0] * self.yule_x[0];
for i in 1..11 {
yule_out += self.yule_b[i] * self.yule_x[i] - self.yule_a[i] * self.yule_y[i];
}
self.yule_y[0] = yule_out;
for i in (1..3).rev() {
self.butter_x[i] = self.butter_x[i - 1];
self.butter_y[i] = self.butter_y[i - 1];
}
self.butter_x[0] = yule_out;
let mut butter_out = self.butter_b[0] * self.butter_x[0];
for i in 1..3 {
butter_out += self.butter_b[i] * self.butter_x[i] - self.butter_a[i] * self.butter_y[i];
}
self.butter_y[0] = butter_out;
butter_out
}
}
#[cfg(feature = "replaygain")]
fn calculate_rms_windows(samples: &[f64], sample_rate: u32) -> Vec<f64> {
let window_size = (sample_rate as usize * 50) / 1000;
if window_size == 0 || samples.len() < window_size {
return Vec::new();
}
let num_windows = samples.len() / window_size;
let mut rms_values = Vec::with_capacity(num_windows);
for i in 0..num_windows {
let start = i * window_size;
let end = start + window_size;
let sum_squares: f64 = samples[start..end].iter().map(|s| s * s).sum();
let rms = (sum_squares / window_size as f64).sqrt();
rms_values.push(rms);
}
rms_values
}
#[cfg(feature = "replaygain")]
fn calculate_loudness(rms_values: &[f64]) -> f64 {
if rms_values.is_empty() {
return -70.0; }
let min_rms = 10.0_f64.powf(-70.0 / 20.0);
let mut filtered: Vec<f64> = rms_values
.iter()
.filter(|&&v| v > min_rms)
.copied()
.collect();
if filtered.is_empty() {
return -70.0;
}
filtered.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((filtered.len() as f64 * 0.95) as usize).saturating_sub(1);
let percentile_rms = filtered[idx.min(filtered.len() - 1)];
if percentile_rms > 0.0 {
20.0 * percentile_rms.log10()
} else {
-70.0
}
}
#[cfg(feature = "replaygain")]
fn detect_file_type(file_path: &Path) -> AudioFileType {
if mp4meta::is_mp4_file(file_path) {
AudioFileType::Aac
} else {
AudioFileType::Mp3
}
}
#[cfg(feature = "replaygain")]
pub fn analyze_track(file_path: &Path) -> Result<ReplayGainResult> {
let file_type = detect_file_type(file_path);
let file = std::fs::File::open(file_path)
.with_context(|| format!("Failed to open: {}", file_path.display()))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.with_context(|| format!("Failed to probe format: {}", file_path.display()))?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.ok_or_else(|| anyhow::anyhow!("No audio track found"))?;
let track_id = track.id;
let sample_rate = track
.codec_params
.sample_rate
.ok_or_else(|| anyhow::anyhow!("Unknown sample rate"))?;
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.with_context(|| "Failed to create decoder")?;
let mut filters: Vec<EqualLoudnessFilter> = (0..channels)
.map(|_| EqualLoudnessFilter::new(sample_rate))
.collect();
let mut all_filtered_samples: Vec<f64> = Vec::new();
let mut peak: f64 = 0.0;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(e) => return Err(e.into()),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(e) => return Err(e.into()),
};
process_audio_buffer(&decoded, &mut filters, &mut all_filtered_samples, &mut peak);
}
let rms_values = calculate_rms_windows(&all_filtered_samples, sample_rate);
let loudness_db = calculate_loudness(&rms_values);
let gain_db = REPLAYGAIN_REFERENCE_DB + loudness_db;
Ok(ReplayGainResult {
loudness_db,
gain_db,
peak,
sample_rate,
file_type,
})
}
#[cfg(feature = "replaygain")]
fn process_audio_buffer(
buffer: &AudioBufferRef,
filters: &mut [EqualLoudnessFilter],
all_samples: &mut Vec<f64>,
peak: &mut f64,
) {
match buffer {
AudioBufferRef::F32(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
let sample = buf.chan(ch)[frame] as f64;
*peak = peak.max(sample.abs());
let filtered = if ch < filters.len() {
filters[ch].process(sample)
} else {
sample
};
sum += filtered * filtered;
}
all_samples.push((sum / channels as f64).sqrt());
}
}
AudioBufferRef::S16(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let scale = 1.0 / 32768.0;
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
let sample = buf.chan(ch)[frame] as f64 * scale;
*peak = peak.max(sample.abs());
let filtered = if ch < filters.len() {
filters[ch].process(sample)
} else {
sample
};
sum += filtered * filtered;
}
all_samples.push((sum / channels as f64).sqrt());
}
}
AudioBufferRef::S32(buf) => {
let channels = buf.spec().channels.count();
let frames = buf.frames();
let scale = 1.0 / 2147483648.0;
for frame in 0..frames {
let mut sum = 0.0;
for ch in 0..channels {
let sample = buf.chan(ch)[frame] as f64 * scale;
*peak = peak.max(sample.abs());
let filtered = if ch < filters.len() {
filters[ch].process(sample)
} else {
sample
};
sum += filtered * filtered;
}
all_samples.push((sum / channels as f64).sqrt());
}
}
_ => {
}
}
}
#[cfg(feature = "replaygain")]
pub fn analyze_album(files: &[&Path]) -> Result<AlbumGainResult> {
let mut track_results = Vec::with_capacity(files.len());
let mut album_peak: f64 = 0.0;
for file in files {
let result = analyze_track(file)?;
album_peak = album_peak.max(result.peak);
track_results.push(result);
}
let total_linear: f64 = track_results
.iter()
.map(|r| 10.0_f64.powf(r.loudness_db / 10.0))
.sum();
let album_loudness_db = 10.0 * (total_linear / track_results.len() as f64).log10();
let album_gain_db = REPLAYGAIN_REFERENCE_DB + album_loudness_db;
Ok(AlbumGainResult {
tracks: track_results,
album_loudness_db,
album_gain_db,
album_peak,
})
}
#[cfg(not(feature = "replaygain"))]
pub fn analyze_track(_file_path: &Path) -> Result<ReplayGainResult> {
anyhow::bail!(
"ReplayGain analysis requires the 'replaygain' feature.\n\
Install with: cargo install mp3rgain --features replaygain"
)
}
#[cfg(not(feature = "replaygain"))]
pub fn analyze_album(_files: &[&Path]) -> Result<AlbumGainResult> {
anyhow::bail!(
"ReplayGain analysis requires the 'replaygain' feature.\n\
Install with: cargo install mp3rgain --features replaygain"
)
}
pub fn is_available() -> bool {
cfg!(feature = "replaygain")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replaygain_availability() {
let available = is_available();
#[cfg(feature = "replaygain")]
assert!(available);
#[cfg(not(feature = "replaygain"))]
assert!(!available);
}
#[cfg(feature = "replaygain")]
#[test]
fn test_filter_creation() {
let filter = EqualLoudnessFilter::new(44100);
assert_eq!(filter.yule_a.len(), 11);
assert_eq!(filter.butter_a.len(), 3);
}
#[cfg(feature = "replaygain")]
#[test]
fn test_rms_calculation() {
let sample_rate = 44100;
let duration_samples = sample_rate; let frequency = 1000.0;
let amplitude = 0.5;
let samples: Vec<f64> = (0..duration_samples)
.map(|i| {
let t = i as f64 / sample_rate as f64;
amplitude * (2.0 * std::f64::consts::PI * frequency * t).sin()
})
.collect();
let rms_values = calculate_rms_windows(&samples, sample_rate as u32);
assert!(!rms_values.is_empty());
let expected_rms = amplitude / std::f64::consts::SQRT_2;
for rms in &rms_values {
assert!(
(*rms - expected_rms).abs() < 0.01,
"RMS {} differs from expected {}",
rms,
expected_rms
);
}
}
#[cfg(feature = "replaygain")]
#[test]
fn test_loudness_calculation() {
let rms_values: Vec<f64> = vec![0.1, 0.1, 0.1, 0.1, 0.1];
let loudness = calculate_loudness(&rms_values);
assert!((loudness - (-20.0)).abs() < 0.1);
}
}