Skip to content

Commit d8c1d71

Browse files
m_igashim_igashi
authored andcommitted
feat: add AAC/M4A ReplayGain support (issue #17)
- Add mp4meta module for reading/writing iTunes freeform metadata - Support ReplayGain tag writing to M4A files (com.apple.iTunes format) - Integrate AAC audio analysis using symphonia's aac/isomp4 features - Update file collection to include .m4a, .aac, .mp4 extensions - Handle MP4 moov/udta/meta/ilst atom structure - Update stco/co64 chunk offsets when modifying metadata Note: For AAC files, only ReplayGain tags are written (no audio modification) as AAC lacks a lossless gain adjustment mechanism like MP3's global_gain.
1 parent e20201e commit d8c1d71

File tree

6 files changed

+1154
-13
lines changed

6 files changed

+1154
-13
lines changed

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mp3rgain"
3-
version = "0.8.0"
3+
version = "0.9.0"
44
edition = "2021"
55
authors = ["Masanari Higashi <M-Igashi@users.noreply.github.com>"]
66
description = "Lossless MP3 volume adjustment - a modern mp3gain replacement written in Rust"
@@ -14,11 +14,13 @@ categories = ["multimedia::audio", "command-line-utilities"]
1414
[features]
1515
default = []
1616
replaygain = ["symphonia"]
17+
aac = ["symphonia-aac"]
18+
symphonia-aac = ["symphonia"]
1719

1820
[dependencies]
1921
anyhow = "1.0"
2022
colored = "2.0"
21-
symphonia = { version = "0.5", optional = true, default-features = false, features = ["mp3"] }
23+
symphonia = { version = "0.5", optional = true, default-features = false, features = ["mp3", "aac", "isomp4"] }
2224
serde = { version = "1.0", features = ["derive"] }
2325
serde_json = "1.0"
2426
indicatif = "0.17"

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88

99
mp3rgain adjusts MP3 volume without re-encoding by modifying the `global_gain` field in each frame's side information. This preserves audio quality while achieving permanent volume changes.
1010

11+
**NEW in v0.9.0**: AAC/M4A support - analyze and write ReplayGain tags to M4A files!
12+
1113
## Features
1214

1315
- **Lossless**: No re-encoding, preserves original audio quality
1416
- **Fast**: Direct binary manipulation, no audio decoding required
1517
- **Reversible**: All changes can be undone (stored in APEv2 tags)
1618
- **ReplayGain**: Track and album gain analysis (optional feature)
19+
- **AAC/M4A Support**: Analyze and tag M4A files with ReplayGain metadata
1720
- **Zero dependencies**: Single static binary (no ffmpeg, no mp3gain)
1821
- **Cross-platform**: macOS, Linux, Windows (x86_64 and ARM64)
1922
- **mp3gain compatible**: Full command-line compatibility with original mp3gain
@@ -91,6 +94,25 @@ mp3rgain -r *.mp3
9194
mp3rgain -a *.mp3
9295
```
9396

97+
### AAC/M4A Support (requires `--features replaygain`)
98+
99+
```bash
100+
# Analyze and tag M4A files with ReplayGain
101+
mp3rgain -r song.m4a
102+
mp3rgain -r *.m4a
103+
104+
# Album gain for M4A files
105+
mp3rgain -a *.m4a
106+
107+
# Mix MP3 and M4A files
108+
mp3rgain -r *.mp3 *.m4a
109+
110+
# Recursive directory processing includes M4A files
111+
mp3rgain -R /path/to/music
112+
```
113+
114+
Note: For M4A files, mp3rgain writes ReplayGain tags (iTunes freeform format) but does not modify the audio data, as AAC doesn't have a lossless gain adjustment mechanism like MP3's `global_gain` field.
115+
94116
### Undo previous adjustment
95117

96118
```bash
@@ -270,22 +292,33 @@ MP3 files contain a `global_gain` field in each frame's side information that co
270292

271293
### ReplayGain Analysis
272294

273-
When built with the `replaygain` feature, mp3rgain uses the [symphonia](https://github.com/pdrat/symphonia) crate for MP3 decoding and implements the ReplayGain 1.0 algorithm:
295+
When built with the `replaygain` feature, mp3rgain uses the [symphonia](https://github.com/pdrat/symphonia) crate for audio decoding and implements the ReplayGain 1.0 algorithm:
274296

275-
1. Decode MP3 to PCM audio
297+
1. Decode MP3/AAC to PCM audio
276298
2. Apply equal-loudness filter (Yule-Walker + Butterworth)
277299
3. Calculate RMS loudness in 50ms windows
278300
4. Use 95th percentile for loudness measurement
279301
5. Calculate gain to reach 89 dB reference level
280302

303+
### AAC/M4A Support
304+
305+
For AAC/M4A files, mp3rgain:
306+
- Analyzes audio loudness using the same ReplayGain algorithm as MP3
307+
- Writes ReplayGain tags in iTunes freeform format (`com.apple.iTunes:replaygain_*`)
308+
- Does NOT modify audio data (AAC lacks a lossless gain mechanism)
309+
310+
Players that support ReplayGain tags will automatically apply volume normalization during playback.
311+
281312
### Compatibility
282313

283314
- MPEG1 Layer III (MP3)
284315
- MPEG2 Layer III
285316
- MPEG2.5 Layer III
317+
- AAC/M4A (ReplayGain tags only)
286318
- Mono, Stereo, Joint Stereo, Dual Channel
287319
- ID3v2 tags (preserved)
288320
- APEv2 tags (for undo support)
321+
- iTunes metadata (for M4A files)
289322
- VBR and CBR files
290323

291324
## Why mp3rgain?

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
//! Each gain step equals 1.5 dB (fixed by MP3 specification).
3838
//! The global_gain field is 8 bits, allowing values 0-255.
3939
40+
pub mod mp4meta;
4041
pub mod replaygain;
4142

4243
use anyhow::{Context, Result};

src/main.rs

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
use anyhow::Result;
77
use colored::*;
88
use indicatif::{ProgressBar, ProgressStyle};
9-
use mp3rgain::replaygain::{self, ReplayGainResult, REPLAYGAIN_REFERENCE_DB};
9+
use mp3rgain::mp4meta;
10+
use mp3rgain::replaygain::{self, AudioFileType, ReplayGainResult, REPLAYGAIN_REFERENCE_DB};
1011
use mp3rgain::{
1112
analyze, apply_gain_channel_with_undo, apply_gain_with_undo, apply_gain_with_undo_wrap,
1213
db_to_steps, delete_ape_tag, find_max_amplitude, read_ape_tag_from_file, steps_to_db,
@@ -47,6 +48,12 @@ enum StoredTagMode {
4748
UseApev2, // -s a: Use APEv2 tags (default)
4849
}
4950

51+
/// Album gain info for AAC files
52+
struct AacAlbumInfo {
53+
album_gain_db: f64,
54+
album_peak: f64,
55+
}
56+
5057
#[derive(Default)]
5158
struct Options {
5259
// Gain options
@@ -399,7 +406,7 @@ fn expand_files_recursive(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
399406

400407
for path in paths {
401408
if path.is_dir() {
402-
collect_mp3_files(path, &mut result)?;
409+
collect_audio_files(path, &mut result)?;
403410
} else {
404411
result.push(path.clone());
405412
}
@@ -409,15 +416,19 @@ fn expand_files_recursive(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
409416
Ok(result)
410417
}
411418

412-
fn collect_mp3_files(dir: &Path, result: &mut Vec<PathBuf>) -> Result<()> {
419+
fn collect_audio_files(dir: &Path, result: &mut Vec<PathBuf>) -> Result<()> {
413420
for entry in std::fs::read_dir(dir)? {
414421
let entry = entry?;
415422
let path = entry.path();
416423

417424
if path.is_dir() {
418-
collect_mp3_files(&path, result)?;
425+
collect_audio_files(&path, result)?;
419426
} else if let Some(ext) = path.extension() {
420-
if ext.eq_ignore_ascii_case("mp3") {
427+
if ext.eq_ignore_ascii_case("mp3")
428+
|| ext.eq_ignore_ascii_case("m4a")
429+
|| ext.eq_ignore_ascii_case("aac")
430+
|| ext.eq_ignore_ascii_case("mp4")
431+
{
421432
result.push(path);
422433
}
423434
}
@@ -437,7 +448,7 @@ fn run(mut opts: Options) -> Result<()> {
437448
if opts.recursive {
438449
opts.files = expand_files_recursive(&opts.files)?;
439450
if opts.files.is_empty() {
440-
eprintln!("{}: no MP3 files found", "error".red().bold());
451+
eprintln!("{}: no audio files found (MP3/M4A)", "error".red().bold());
441452
std::process::exit(1);
442453
}
443454
}
@@ -1376,7 +1387,17 @@ fn cmd_album_gain(files: &[PathBuf], opts: &Options) -> Result<()> {
13761387
progress_set_message(&pb, filename);
13771388

13781389
let track_result = &album_result.tracks[i];
1379-
let result = process_apply_replaygain(file, steps, track_result, opts)?;
1390+
let album_info = AacAlbumInfo {
1391+
album_gain_db: album_result.album_gain_db,
1392+
album_peak: album_result.album_peak,
1393+
};
1394+
let result = process_apply_replaygain_with_album(
1395+
file,
1396+
steps,
1397+
track_result,
1398+
opts,
1399+
Some(&album_info),
1400+
)?;
13801401
if opts.output_format == OutputFormat::Json {
13811402
if result.status.as_deref() == Some("success") {
13821403
successful += 1;
@@ -1930,6 +1951,16 @@ fn process_apply_replaygain(
19301951
steps: i32,
19311952
result: &ReplayGainResult,
19321953
opts: &Options,
1954+
) -> Result<JsonFileResult> {
1955+
process_apply_replaygain_with_album(file, steps, result, opts, None)
1956+
}
1957+
1958+
fn process_apply_replaygain_with_album(
1959+
file: &PathBuf,
1960+
steps: i32,
1961+
result: &ReplayGainResult,
1962+
opts: &Options,
1963+
album_info: Option<&AacAlbumInfo>,
19331964
) -> Result<JsonFileResult> {
19341965
let filename = file
19351966
.file_name()
@@ -1998,12 +2029,17 @@ fn process_apply_replaygain(
19982029
// Dry run: don't actually modify
19992030
if opts.dry_run {
20002031
if opts.output_format == OutputFormat::Text && !opts.quiet {
2032+
let format_info = match result.file_type {
2033+
AudioFileType::Aac => " (tags only)",
2034+
AudioFileType::Mp3 => "",
2035+
};
20012036
println!(
2002-
" {} [DRY RUN] {} (would apply {:+.1} dB, {} steps)",
2037+
" {} [DRY RUN] {} (would apply {:+.1} dB, {} steps{})",
20032038
"~".cyan(),
20042039
filename,
20052040
steps_to_db(actual_steps),
2006-
actual_steps
2041+
actual_steps,
2042+
format_info
20072043
);
20082044
}
20092045
return Ok(JsonFileResult {
@@ -2019,6 +2055,20 @@ fn process_apply_replaygain(
20192055
});
20202056
}
20212057

2058+
// Handle AAC/M4A files differently - only write ReplayGain tags
2059+
if result.file_type == AudioFileType::Aac {
2060+
return process_apply_replaygain_aac_with_album(
2061+
file,
2062+
actual_steps,
2063+
result,
2064+
opts,
2065+
warning_msg,
2066+
original_mtime,
2067+
album_info,
2068+
);
2069+
}
2070+
2071+
// MP3: Apply gain to audio frames
20222072
let apply_result = if opts.wrap_gain {
20232073
apply_with_temp_file(file, |f| apply_gain_with_undo_wrap(f, actual_steps), opts)
20242074
} else {
@@ -2069,6 +2119,80 @@ fn process_apply_replaygain(
20692119
}
20702120
}
20712121

2122+
/// Apply ReplayGain to AAC/M4A files with optional album info
2123+
fn process_apply_replaygain_aac_with_album(
2124+
file: &PathBuf,
2125+
_actual_steps: i32,
2126+
result: &ReplayGainResult,
2127+
opts: &Options,
2128+
warning_msg: Option<String>,
2129+
original_mtime: Option<std::time::SystemTime>,
2130+
album_info: Option<&AacAlbumInfo>,
2131+
) -> Result<JsonFileResult> {
2132+
let filename = file
2133+
.file_name()
2134+
.and_then(|n| n.to_str())
2135+
.unwrap_or("unknown");
2136+
2137+
// Create ReplayGain tags for AAC
2138+
let mut tags = mp4meta::ReplayGainTags::new();
2139+
tags.set_track(result.gain_db, result.peak);
2140+
2141+
// Add album tags if available
2142+
if let Some(album) = album_info {
2143+
tags.set_album(album.album_gain_db, album.album_peak);
2144+
}
2145+
2146+
// Write tags to file
2147+
match mp4meta::write_replaygain_tags(file, &tags) {
2148+
Ok(()) => {
2149+
// Restore timestamp if needed
2150+
if let Some(mtime) = original_mtime {
2151+
restore_timestamp(file, mtime);
2152+
}
2153+
2154+
let tag_type = if album_info.is_some() {
2155+
"track+album tags"
2156+
} else {
2157+
"tags"
2158+
};
2159+
2160+
if opts.output_format == OutputFormat::Text && !opts.quiet {
2161+
println!(
2162+
" {} {} ({} written, {:+.1} dB)",
2163+
"v".green(),
2164+
filename,
2165+
tag_type,
2166+
result.gain_db
2167+
);
2168+
}
2169+
2170+
Ok(JsonFileResult {
2171+
file: file.display().to_string(),
2172+
status: Some("success".to_string()),
2173+
loudness_db: Some(result.loudness_db),
2174+
peak: Some(result.peak),
2175+
gain_applied_steps: Some(result.gain_steps()),
2176+
gain_applied_db: Some(result.gain_db),
2177+
warning: warning_msg,
2178+
..Default::default()
2179+
})
2180+
}
2181+
Err(e) => {
2182+
if opts.output_format == OutputFormat::Text && !opts.quiet {
2183+
eprintln!(" {} {} - {}", "x".red(), filename, e);
2184+
}
2185+
2186+
Ok(JsonFileResult {
2187+
file: file.display().to_string(),
2188+
status: Some("error".to_string()),
2189+
error: Some(e.to_string()),
2190+
..Default::default()
2191+
})
2192+
}
2193+
}
2194+
}
2195+
20722196
fn restore_timestamp(file: &PathBuf, mtime: SystemTime) {
20732197
let _ = std::fs::File::options()
20742198
.write(true)

0 commit comments

Comments
 (0)