#[cfg(feature = "aac")]
pub mod aac;
#[cfg(feature = "aac")]
mod aac_codebooks;
pub mod analysis;
pub mod ape;
pub mod apply;
#[cfg(feature = "replaygain")]
pub mod bs1770;
pub mod error;
mod frame;
pub mod gain;
pub mod id3v2;
pub mod mp4meta;
pub mod replaygain;
pub use analysis::{
analyze, analyze_data, find_max_amplitude, is_mono, ChannelMode, MaxAmplitudeResult,
Mp3Analysis, MpegVersion,
};
pub use ape::{
delete_ape_tag, read_ape_tag, read_ape_tag_from_file, write_ape_album_minmax, write_ape_tag,
ApeItem, ApeTag, TAG_MP3GAIN_ALBUM_MINMAX, TAG_MP3GAIN_MINMAX, TAG_MP3GAIN_UNDO,
TAG_REPLAYGAIN_ALBUM_GAIN, TAG_REPLAYGAIN_ALBUM_PEAK, TAG_REPLAYGAIN_ALGORITHM,
TAG_REPLAYGAIN_TRACK_GAIN, TAG_REPLAYGAIN_TRACK_PEAK,
};
pub use apply::{
apply_with_options, predict_apply, write_album_minmax, write_replaygain_tags_only,
AacAlbumInfo, ApplyOptions, ApplyReport, ClippingDetection, TagsOnlyOptions,
};
pub use error::{Error, Result};
pub use gain::{
apply_gain, apply_gain_db, apply_gain_to_peak, db_to_linear, db_to_steps, peak_to_headroom_db,
peak_to_pcm_sample, steps_to_db, undo_gain, would_clip, Channel, GainOptions, GAIN_STEP_DB,
MAX_GAIN,
};
pub use id3v2::{
delete_id3v2_replaygain, read_id3v2_replaygain, undo_gain_id3v2, write_id3v2_replaygain,
Id3v2ReplayGain,
};
use std::path::{Path, PathBuf};
pub const SUPPORTED_EXTENSIONS: &[&str] = &["mp3", "m4a", "aac", "mp4"];
pub fn is_supported_audio_path(path: &Path) -> bool {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("._"))
{
return false;
}
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
SUPPORTED_EXTENSIONS
.iter()
.any(|s| ext.eq_ignore_ascii_case(s))
})
}
pub fn collect_audio_files(dir: &Path, recursive: bool) -> Result<Vec<PathBuf>> {
let mut result = Vec::new();
collect_audio_files_into(dir, recursive, &mut result)?;
Ok(result)
}
pub fn apply_gain_db_auto(file_path: &Path, gain_db: f64) -> Result<usize> {
#[cfg(feature = "aac")]
{
if mp4meta::is_aac_file(file_path) {
return aac::apply_aac_gain_to_path(file_path, file_path, gain::db_to_steps(gain_db));
}
}
gain::apply_gain_db(file_path, gain_db)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TagLayout {
#[default]
Split,
Ape,
Id3v2,
}
impl TagLayout {
pub fn replaygain_in_id3v2(self) -> bool {
matches!(self, TagLayout::Split | TagLayout::Id3v2)
}
pub fn mp3gain_in_id3v2(self) -> bool {
matches!(self, TagLayout::Id3v2)
}
}
pub fn undo_gain_auto(file_path: &Path, layout: TagLayout) -> Result<usize> {
#[cfg(feature = "aac")]
{
if mp4meta::is_aac_file(file_path) {
return aac::undo_aac_gain(file_path);
}
}
let ape_has_undo = || {
ape::read_ape_tag_from_file(file_path)
.ok()
.flatten()
.is_some_and(|t| t.get(TAG_MP3GAIN_UNDO).is_some())
};
let id3v2_has_undo = || {
id3v2::read_id3v2_replaygain(file_path)
.ok()
.is_some_and(|rg| rg.undo.is_some())
};
let use_id3v2 = if layout.mp3gain_in_id3v2() {
id3v2_has_undo() || !ape_has_undo()
} else {
!ape_has_undo() && id3v2_has_undo()
};
if use_id3v2 {
id3v2::undo_gain_id3v2(file_path)
} else {
gain::undo_gain(file_path)
}
}
pub fn delete_gain_tags_auto(file_path: &Path, layout: TagLayout) -> Result<()> {
#[cfg(feature = "aac")]
{
if mp4meta::is_aac_file(file_path) {
mp4meta::delete_replaygain_tags(file_path)?;
return mp4meta::delete_undo_tags(file_path);
}
}
match layout {
TagLayout::Id3v2 => id3v2::delete_id3v2_replaygain(file_path),
TagLayout::Ape => ape::delete_ape_tag(file_path),
TagLayout::Split => {
id3v2::delete_id3v2_replaygain(file_path)?;
ape::delete_ape_tag(file_path)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GainTagSource {
Aac,
Id3v2,
Ape { tag_present: bool },
Split,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredGainTags {
pub source: GainTagSource,
pub track_gain: Option<String>,
pub track_peak: Option<String>,
pub album_gain: Option<String>,
pub album_peak: Option<String>,
pub algorithm: Option<String>,
pub undo: Option<String>,
pub minmax: Option<String>,
pub album_minmax: Option<String>,
}
impl StoredGainTags {
pub fn empty(source: GainTagSource) -> Self {
Self {
source,
track_gain: None,
track_peak: None,
album_gain: None,
album_peak: None,
algorithm: None,
undo: None,
minmax: None,
album_minmax: None,
}
}
pub fn track_gain_db(&self) -> Option<f64> {
self.track_gain.as_deref().and_then(ape::parse_rg_gain)
}
pub fn track_peak_value(&self) -> Option<f64> {
self.track_peak.as_deref().and_then(ape::parse_rg_peak)
}
pub fn album_gain_db(&self) -> Option<f64> {
self.album_gain.as_deref().and_then(ape::parse_rg_gain)
}
pub fn album_peak_value(&self) -> Option<f64> {
self.album_peak.as_deref().and_then(ape::parse_rg_peak)
}
pub fn rg1_track_values(&self) -> Option<(f64, f64)> {
if self.algorithm.is_some() {
return None;
}
Some((self.track_gain_db()?, self.track_peak_value()?))
}
pub fn rg1_album_values(&self) -> Option<StoredAlbumValues> {
let (track_gain_db, track_peak) = self.rg1_track_values()?;
Some(StoredAlbumValues {
track_gain_db,
track_peak,
album_gain_db: self.album_gain_db()?,
album_peak: self.album_peak_value()?,
})
}
pub fn has_any(&self) -> bool {
self.track_gain.is_some()
|| self.track_peak.is_some()
|| self.album_gain.is_some()
|| self.album_peak.is_some()
|| self.algorithm.is_some()
|| self.undo.is_some()
|| self.minmax.is_some()
|| self.album_minmax.is_some()
}
}
pub fn read_gain_tags_auto(file_path: &Path, layout: TagLayout) -> Result<StoredGainTags> {
#[cfg(feature = "aac")]
{
if mp4meta::is_aac_file(file_path) {
let (undo_tags, rg_tags) = mp4meta::read_gain_tags(file_path).unwrap_or_default();
return Ok(StoredGainTags {
source: GainTagSource::Aac,
track_gain: rg_tags.track_gain().map(str::to_string),
track_peak: rg_tags.track_peak().map(str::to_string),
album_gain: rg_tags.album_gain().map(str::to_string),
album_peak: rg_tags.album_peak().map(str::to_string),
algorithm: rg_tags.algorithm().map(str::to_string),
undo: undo_tags.undo().map(str::to_string),
minmax: undo_tags.minmax().map(str::to_string),
album_minmax: None,
});
}
}
if layout.mp3gain_in_id3v2() {
let rg = id3v2::read_id3v2_replaygain(file_path)?;
return Ok(StoredGainTags {
source: GainTagSource::Id3v2,
track_gain: rg.track_gain,
track_peak: rg.track_peak,
album_gain: rg.album_gain,
album_peak: rg.album_peak,
algorithm: rg.algorithm,
undo: rg.undo,
minmax: rg.minmax,
album_minmax: None,
});
}
if layout == TagLayout::Split {
let id3 = id3v2::read_id3v2_replaygain(file_path)?;
let ape_tag = ape::read_ape_tag_from_file(file_path)?;
let ape_get = |key: &str| {
ape_tag
.as_ref()
.and_then(|t| t.get(key))
.map(str::to_string)
};
return Ok(StoredGainTags {
source: GainTagSource::Split,
track_gain: id3
.track_gain
.or_else(|| ape_get(TAG_REPLAYGAIN_TRACK_GAIN)),
track_peak: id3
.track_peak
.or_else(|| ape_get(TAG_REPLAYGAIN_TRACK_PEAK)),
album_gain: id3
.album_gain
.or_else(|| ape_get(TAG_REPLAYGAIN_ALBUM_GAIN)),
album_peak: id3
.album_peak
.or_else(|| ape_get(TAG_REPLAYGAIN_ALBUM_PEAK)),
algorithm: id3.algorithm.or_else(|| ape_get(TAG_REPLAYGAIN_ALGORITHM)),
undo: ape_get(TAG_MP3GAIN_UNDO).or(id3.undo),
minmax: ape_get(TAG_MP3GAIN_MINMAX).or(id3.minmax),
album_minmax: ape_get(TAG_MP3GAIN_ALBUM_MINMAX),
});
}
match ape::read_ape_tag_from_file(file_path)? {
Some(tag) => Ok(StoredGainTags {
source: GainTagSource::Ape { tag_present: true },
track_gain: tag.get(TAG_REPLAYGAIN_TRACK_GAIN).map(str::to_string),
track_peak: tag.get(TAG_REPLAYGAIN_TRACK_PEAK).map(str::to_string),
album_gain: tag.get(TAG_REPLAYGAIN_ALBUM_GAIN).map(str::to_string),
album_peak: tag.get(TAG_REPLAYGAIN_ALBUM_PEAK).map(str::to_string),
algorithm: tag.get(TAG_REPLAYGAIN_ALGORITHM).map(str::to_string),
undo: tag.get(TAG_MP3GAIN_UNDO).map(str::to_string),
minmax: tag.get(TAG_MP3GAIN_MINMAX).map(str::to_string),
album_minmax: tag.get(TAG_MP3GAIN_ALBUM_MINMAX).map(str::to_string),
}),
None => Ok(StoredGainTags::empty(GainTagSource::Ape {
tag_present: false,
})),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StoredAlbumValues {
pub track_gain_db: f64,
pub track_peak: f64,
pub album_gain_db: f64,
pub album_peak: f64,
}
pub const ALBUM_GAIN_TOLERANCE_DB: f64 = 0.05;
pub fn consistent_album_gain(values: impl IntoIterator<Item = (f64, f64)>) -> Option<(f64, f64)> {
let mut album_gain: Option<f64> = None;
let mut album_peak: f64 = 0.0;
for (gain, peak) in values {
album_peak = album_peak.max(peak);
match album_gain {
Some(g) if (g - gain).abs() > ALBUM_GAIN_TOLERANCE_DB => return None,
Some(_) => {}
None => album_gain = Some(gain),
}
}
Some((album_gain?, album_peak))
}
pub fn expand_audio_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut result = Vec::with_capacity(paths.len());
for path in paths {
if path.is_dir() {
result.extend(collect_audio_files(path, true)?);
} else {
result.push(path.clone());
}
}
Ok(result)
}
pub fn read_undo_steps(file_path: &Path, layout: TagLayout) -> Option<i32> {
#[cfg(feature = "aac")]
{
if mp4meta::is_aac_file(file_path) {
let undo_tags = mp4meta::read_undo_tags(file_path).ok()?;
return Some(ape::parse_undo_values(undo_tags.undo()).0);
}
}
let from_ape = || {
ape::read_ape_tag_from_file(file_path)
.ok()
.flatten()
.and_then(|t| t.get_undo_gain())
.map(i32::wrapping_neg)
};
let from_id3v2 = || {
let rg = id3v2::read_id3v2_replaygain(file_path).ok()?;
rg.undo
.as_deref()
.map(|u| ape::parse_undo_values(Some(u)).0.wrapping_neg())
};
if layout.mp3gain_in_id3v2() {
from_id3v2().or_else(from_ape)
} else {
from_ape().or_else(from_id3v2)
}
}
fn collect_audio_files_into(dir: &Path, recursive: bool, result: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir).map_err(|e| Error::io_read(dir, e))?;
for entry in entries {
let entry = entry.map_err(|e| Error::io_read(dir, e))?;
let file_type = entry.file_type().map_err(|e| Error::io_read(dir, e))?;
let path = entry.path();
if file_type.is_dir() {
if recursive {
collect_audio_files_into(&path, recursive, result)?;
}
} else if is_supported_audio_path(&path) {
result.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod undo_steps_tests {
use super::*;
use std::io::Write;
fn write_temp(name: &str, data: &[u8]) -> PathBuf {
let dir = std::env::temp_dir().join("mp3rgain_undo_steps_tests");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join(name);
std::fs::File::create(&path)
.unwrap()
.write_all(data)
.unwrap();
path
}
#[test]
fn read_undo_steps_reports_applied_gain_for_mp3() {
let mut tag = ape::ApeTag::new();
tag.set_undo_gain(-4, -4, false);
let data = ape::replace_ape_tag(&vec![0u8; 8_000], &tag);
let path = write_temp("applied_plus4.mp3", &data);
for layout in [TagLayout::Split, TagLayout::Ape] {
assert_eq!(
read_undo_steps(&path, layout),
Some(4),
"{layout:?} should report the applied gain, not the stored delta"
);
}
let mut tag = ape::ApeTag::new();
tag.set_undo_gain(3, 3, false);
let data = ape::replace_ape_tag(&vec![0u8; 8_000], &tag);
let path = write_temp("applied_minus3.mp3", &data);
assert_eq!(read_undo_steps(&path, TagLayout::Split), Some(-3));
let path = write_temp("untagged.mp3", &vec![0u8; 8_000]);
assert_eq!(read_undo_steps(&path, TagLayout::Split), None);
}
}
#[cfg(test)]
mod stored_tag_tests {
use super::*;
fn tags(track: Option<&str>, peak: Option<&str>, algorithm: Option<&str>) -> StoredGainTags {
StoredGainTags {
track_gain: track.map(str::to_string),
track_peak: peak.map(str::to_string),
algorithm: algorithm.map(str::to_string),
..StoredGainTags::empty(GainTagSource::Split)
}
}
#[test]
fn rg1_track_values_requires_both_values_and_no_algorithm_marker() {
let (gain, peak) = tags(Some("+1.500000 dB"), Some("0.912345"), None)
.rg1_track_values()
.unwrap();
assert!((gain - 1.5).abs() < 1e-9);
assert!((peak - 0.912345).abs() < 1e-9);
assert!(tags(Some("+1.5 dB"), None, None)
.rg1_track_values()
.is_none());
assert!(tags(None, Some("0.9"), None).rg1_track_values().is_none());
assert!(tags(Some("junk"), Some("0.9"), None)
.rg1_track_values()
.is_none());
assert!(tags(Some("+1.5 dB"), Some("0.9"), Some("ITU-R BS.1770"))
.rg1_track_values()
.is_none());
}
#[test]
fn rg1_album_values_needs_the_album_pair_too() {
let mut t = tags(Some("+1.5 dB"), Some("0.9"), None);
assert!(t.rg1_album_values().is_none());
t.album_gain = Some("-2.000000 dB".into());
t.album_peak = Some("0.999".into());
let v = t.rg1_album_values().unwrap();
assert!((v.album_gain_db - -2.0).abs() < 1e-9);
assert!((v.album_peak - 0.999).abs() < 1e-9);
assert!((v.track_gain_db - 1.5).abs() < 1e-9);
}
#[test]
fn consistent_album_gain_agrees_within_tolerance_and_takes_max_peak() {
let (gain, peak) =
consistent_album_gain([(-3.0, 0.8), (-3.04, 0.95), (-2.97, 0.5)]).unwrap();
assert!(
(gain - -3.0).abs() < 1e-9,
"first member's gain is reported"
);
assert!((peak - 0.95).abs() < 1e-9);
assert!(consistent_album_gain([(-3.0, 0.8), (-3.2, 0.9)]).is_none());
assert!(consistent_album_gain(std::iter::empty()).is_none());
}
}
#[cfg(all(test, feature = "aac"))]
mod auto_dispatch_tests {
use super::*;
use std::io::Write;
#[test]
fn auto_dispatch_does_not_corrupt_mp4_when_payload_mimics_mp3_frame() {
let dir = std::env::temp_dir().join("mp3rgain_issue_149");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("fake.m4a");
let mut bytes = vec![
0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'M', b'4', b'A', b' ', 0x00, 0x00,
0x00, 0x00, b'M', b'4', b'A', b' ', ];
let frame_header = [0xFFu8, 0xE3, 0x10, 0x00];
bytes.extend_from_slice(&frame_header);
bytes.resize(20 + 52, 0x55); bytes.extend_from_slice(&frame_header);
bytes.resize(20 + 52 + 52, 0x55); let original = bytes.clone();
std::fs::File::create(&path)
.unwrap()
.write_all(&bytes)
.unwrap();
let _ = apply_gain_db_auto(&path, 3.0);
let after = std::fs::read(&path).unwrap();
assert_eq!(
after, original,
"MP4 bytes must be untouched by auto dispatch"
);
let _ = std::fs::remove_dir_all(&dir);
}
}