Skip to main content

mp3rgain/
lib.rs

1//! # mp3rgain
2//!
3//! Lossless MP3 volume adjustment library - a modern mp3gain replacement.
4//!
5//! This library provides lossless MP3 volume adjustment by modifying
6//! the `global_gain` field in each frame's side information.
7//!
8//! ## Features
9//!
10//! - **Lossless**: No re-encoding, preserves audio quality
11//! - **Fast**: Direct binary manipulation, no audio decoding
12//! - **Compatible**: Works with all MP3 files (MPEG1/2/2.5 Layer III)
13//! - **Reversible**: Changes can be undone by applying negative gain
14//!
15//! ## Optional Features
16//!
17//! - **replaygain**: Enable ReplayGain analysis (requires symphonia)
18//!   - Track gain calculation (`-r` flag)
19//!   - Album gain calculation (`-a` flag)
20//!
21//! ## Example
22//!
23//! ```no_run
24//! use mp3rgain::{apply_gain, apply_gain_db, analyze, GainOptions, Channel};
25//! use std::path::Path;
26//!
27//! // Simple gain adjustment: +2 steps (+3.0 dB)
28//! let frames = apply_gain(Path::new("song.mp3"), 2).unwrap();
29//! println!("Modified {} frames", frames);
30//!
31//! // Or specify gain in dB directly
32//! let frames = apply_gain_db(Path::new("song.mp3"), 4.5).unwrap();
33//!
34//! // Builder pattern for advanced options
35//! GainOptions::new(5)
36//!     .wrap(true)
37//!     .undo(true)
38//!     .apply(Path::new("song.mp3")).unwrap();
39//!
40//! // Channel-specific gain with undo support
41//! GainOptions::new(3)
42//!     .channel(Channel::Left)
43//!     .undo(true)
44//!     .apply(Path::new("song.mp3")).unwrap();
45//! ```
46//!
47//! ## Modules
48//!
49//! - [`analysis`] - MP3 file analysis and amplitude detection
50//! - [`gain`] - Gain adjustment operations and the [`GainOptions`] builder
51//! - [`ape`] - APEv2 tag reading, writing, and management
52//! - [`replaygain`] - ReplayGain loudness analysis
53//! - [`mp4meta`] - MP4/M4A metadata handling
54//! - [`aac`] - AAC bitstream parsing (feature-gated)
55//!
56//! ## Technical Details
57//!
58//! Each gain step equals 1.5 dB (fixed by MP3 specification).
59//! The global_gain field is 8 bits, allowing values 0-255.
60
61#[cfg(feature = "aac")]
62pub mod aac;
63#[cfg(feature = "aac")]
64mod aac_codebooks;
65
66pub mod analysis;
67pub mod ape;
68pub mod apply;
69pub mod error;
70mod frame;
71pub mod gain;
72pub mod id3v2;
73pub mod mp4meta;
74pub mod replaygain;
75
76pub use analysis::{
77    analyze, find_max_amplitude, is_mono, ChannelMode, MaxAmplitudeResult, Mp3Analysis, MpegVersion,
78};
79pub use ape::{
80    delete_ape_tag, read_ape_tag, read_ape_tag_from_file, write_ape_tag, ApeItem, ApeTag,
81    TAG_MP3GAIN_ALBUM_MINMAX, TAG_MP3GAIN_MINMAX, TAG_MP3GAIN_UNDO, TAG_REPLAYGAIN_ALBUM_GAIN,
82    TAG_REPLAYGAIN_ALBUM_PEAK, TAG_REPLAYGAIN_TRACK_GAIN, TAG_REPLAYGAIN_TRACK_PEAK,
83};
84pub use apply::{
85    apply_with_options, predict_apply, AacAlbumInfo, ApplyOptions, ApplyReport, ClippingDetection,
86};
87pub use error::{Error, Result};
88pub use gain::{
89    apply_gain, apply_gain_db, db_to_steps, peak_to_headroom_db, peak_to_pcm_sample, steps_to_db,
90    undo_gain, Channel, GainOptions, GAIN_STEP_DB, MAX_GAIN, MIN_GAIN,
91};
92pub use id3v2::{
93    delete_id3v2_replaygain, read_id3v2_replaygain, undo_gain_id3v2, write_id3v2_replaygain,
94    write_id3v2_undo, Id3v2ReplayGain,
95};
96
97use std::path::{Path, PathBuf};
98
99/// File extensions mp3rgain can process.
100pub const SUPPORTED_EXTENSIONS: &[&str] = &["mp3", "m4a", "aac", "mp4"];
101
102/// Returns true if `path` is a regular audio file mp3rgain can process.
103/// Filters out macOS resource fork files (`._*`) and unsupported extensions.
104pub fn is_supported_audio_path(path: &Path) -> bool {
105    if path
106        .file_name()
107        .and_then(|n| n.to_str())
108        .is_some_and(|n| n.starts_with("._"))
109    {
110        return false;
111    }
112    path.extension()
113        .and_then(|e| e.to_str())
114        .is_some_and(|ext| {
115            SUPPORTED_EXTENSIONS
116                .iter()
117                .any(|s| ext.eq_ignore_ascii_case(s))
118        })
119}
120
121/// Collect supported audio file paths from a directory.
122///
123/// When `recursive` is true, descends into subdirectories. Files are filtered
124/// by [`is_supported_audio_path`]. The returned paths are not sorted.
125pub fn collect_audio_files(dir: &Path, recursive: bool) -> Result<Vec<PathBuf>> {
126    let mut result = Vec::new();
127    collect_audio_files_into(dir, recursive, &mut result)?;
128    Ok(result)
129}
130
131/// Apply gain in dB, auto-dispatching by file format.
132///
133/// Detects MP4/AAC files via [`mp4meta::is_aac_file`] and routes them through
134/// the AAC pipeline (which rewrites only the AAC `global_gain` bitfields inside
135/// `mdat`). All other files fall back to the MP3 pipeline.
136///
137/// Calling [`gain::apply_gain_db`] directly on an M4A file would scan the raw
138/// bytes for MP3 sync words and overwrite the byte following any match,
139/// corrupting the MP4 container — see issue #149.
140pub fn apply_gain_db_auto(file_path: &Path, gain_db: f64) -> Result<usize> {
141    #[cfg(feature = "aac")]
142    {
143        if mp4meta::is_aac_file(file_path) {
144            return aac::apply_aac_gain_to_path(file_path, file_path, gain::db_to_steps(gain_db));
145        }
146    }
147    gain::apply_gain_db(file_path, gain_db)
148}
149
150fn collect_audio_files_into(dir: &Path, recursive: bool, result: &mut Vec<PathBuf>) -> Result<()> {
151    let entries = std::fs::read_dir(dir).map_err(|e| Error::io_read(dir, e))?;
152    for entry in entries {
153        let entry = entry.map_err(|e| Error::io_read(dir, e))?;
154        let file_type = entry.file_type().map_err(|e| Error::io_read(dir, e))?;
155        let path = entry.path();
156        if file_type.is_dir() {
157            if recursive {
158                collect_audio_files_into(&path, recursive, result)?;
159            }
160        } else if is_supported_audio_path(&path) {
161            result.push(path);
162        }
163    }
164    Ok(())
165}
166
167#[cfg(all(test, feature = "aac"))]
168mod auto_dispatch_tests {
169    use super::*;
170    use std::io::Write;
171
172    /// Regression for issue #149: applying gain to an MP4 file via the
173    /// auto-dispatcher must NOT run the MP3 sync-word scanner, which would
174    /// overwrite bytes inside MP4 atoms whenever they happen to look like a
175    /// valid MPEG L3 frame header and corrupt the container.
176    ///
177    /// The crafted MP4 below embeds a 72-byte MPEG2.5 L3 8kbps frame header
178    /// immediately after the ftyp box. The buggy MP3 path would treat byte 27
179    /// (the `global_gain` location inside the side info) as a writable gain
180    /// slot and rewrite it. The dispatch must hand the file to the AAC path
181    /// (which rejects it cleanly because there's no `mdat`) and leave the
182    /// bytes untouched.
183    #[test]
184    fn auto_dispatch_does_not_corrupt_mp4_when_payload_mimics_mp3_frame() {
185        let dir = std::env::temp_dir().join("mp3rgain_issue_149");
186        let _ = std::fs::create_dir_all(&dir);
187        let path = dir.join("fake.m4a");
188
189        // ftyp box (20 bytes) + two back-to-back MPEG2.5 L3 8kbps/11025Hz stereo
190        // "frames" (52 bytes each). The MP3 scanner validates a frame by looking
191        // for another sync word at next_pos or by next_pos == audio_end — so two
192        // chained frames make the first one parse as valid.
193        let mut bytes = vec![
194            0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'M', b'4', b'A', b' ', 0x00, 0x00,
195            0x00, 0x00, b'M', b'4', b'A', b' ', // ftyp box (20 bytes, accepted brand)
196        ];
197        let frame_header = [0xFFu8, 0xE3, 0x10, 0x00];
198        bytes.extend_from_slice(&frame_header);
199        bytes.resize(20 + 52, 0x55); // pad first frame to 52 bytes
200        bytes.extend_from_slice(&frame_header);
201        bytes.resize(20 + 52 + 52, 0x55); // pad second frame to 52 bytes
202        let original = bytes.clone();
203
204        std::fs::File::create(&path)
205            .unwrap()
206            .write_all(&bytes)
207            .unwrap();
208
209        let _ = apply_gain_db_auto(&path, 3.0);
210
211        let after = std::fs::read(&path).unwrap();
212        assert_eq!(
213            after, original,
214            "MP4 bytes must be untouched by auto dispatch"
215        );
216
217        let _ = std::fs::remove_dir_all(&dir);
218    }
219}