mp3rgain 3.5.1

Lossless MP3 volume adjustment - a modern mp3gain replacement written in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
//! # mp3rgain
//!
//! Lossless MP3 volume adjustment library - a modern mp3gain replacement.
//!
//! This library provides lossless MP3 volume adjustment by modifying
//! the `global_gain` field in each frame's side information.
//!
//! ## Features
//!
//! - **Lossless**: No re-encoding, preserves audio quality
//! - **Fast**: Direct binary manipulation, no audio decoding
//! - **Compatible**: Works with all MP3 files (MPEG1/2/2.5 Layer III)
//! - **Reversible**: Changes can be undone by applying negative gain
//!
//! ## Optional Features
//!
//! - **replaygain**: Enable ReplayGain analysis (requires symphonia)
//!   - Track gain calculation (`-r` flag)
//!   - Album gain calculation (`-a` flag)
//!
//! ## Example
//!
//! ```no_run
//! use mp3rgain::{apply_gain, apply_gain_db, analyze, GainOptions, Channel};
//! use std::path::Path;
//!
//! // Simple gain adjustment: +2 steps (+3.0 dB)
//! let frames = apply_gain(Path::new("song.mp3"), 2).unwrap();
//! println!("Modified {} frames", frames);
//!
//! // Or specify gain in dB directly
//! let frames = apply_gain_db(Path::new("song.mp3"), 4.5).unwrap();
//!
//! // Builder pattern for advanced options
//! GainOptions::new(5)
//!     .wrap(true)
//!     .undo(true)
//!     .apply(Path::new("song.mp3")).unwrap();
//!
//! // Channel-specific gain with undo support
//! GainOptions::new(3)
//!     .channel(Channel::Left)
//!     .undo(true)
//!     .apply(Path::new("song.mp3")).unwrap();
//! ```
//!
//! ## Modules
//!
//! - [`analysis`] - MP3 file analysis and amplitude detection
//! - [`gain`] - Gain adjustment operations and the [`GainOptions`] builder
//! - [`ape`] - APEv2 tag reading, writing, and management
//! - [`replaygain`] - ReplayGain loudness analysis
//! - [`bs1770`] - ITU-R BS.1770 loudness engine for the RG2/R128 modes (feature-gated)
//! - [`mp4meta`] - MP4/M4A metadata handling
//! - [`aac`] - AAC bitstream parsing (feature-gated)
//!
//! ## Technical Details
//!
//! Each gain step scales amplitude by 2^(1/4) ≈ 1.505 dB (fixed by MP3 specification).
//! The global_gain field is 8 bits, allowing values 0-255.

#[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};

/// File extensions mp3rgain can process.
pub const SUPPORTED_EXTENSIONS: &[&str] = &["mp3", "m4a", "aac", "mp4"];

/// Returns true if `path` is a regular audio file mp3rgain can process.
/// Filters out macOS resource fork files (`._*`) and unsupported extensions.
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))
        })
}

/// Collect supported audio file paths from a directory.
///
/// When `recursive` is true, descends into subdirectories. Files are filtered
/// by [`is_supported_audio_path`]. The returned paths are not sorted.
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)
}

/// Apply gain in dB, auto-dispatching by file format.
///
/// Detects MP4/AAC files via [`mp4meta::is_aac_file`] and routes them through
/// the AAC pipeline (which rewrites only the AAC `global_gain` bitfields inside
/// `mdat`). All other files fall back to the MP3 pipeline.
///
/// Calling [`gain::apply_gain_db`] directly on an M4A file would scan the raw
/// bytes for MP3 sync words and overwrite the byte following any match,
/// corrupting the MP4 container — see issue #149.
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)
}

/// Which container MP3 tags are written to.
///
/// The two tag families have different audiences. `REPLAYGAIN_*` is read by
/// players, and they look in ID3v2 — ffmpeg (and everything built on it) does
/// not read APEv2 on MP3 at all, and Rockbox only handles APE tags for WavPack
/// and Musepack. `MP3GAIN_UNDO` / `MP3GAIN_MINMAX` are read by nothing but the
/// mp3gain lineage, which looks in APEv2. [`TagLayout::Split`] therefore sends
/// each family where its readers are, and is the default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TagLayout {
    /// `REPLAYGAIN_*` in ID3v2 TXXX, `MP3GAIN_*` in APEv2. Default.
    #[default]
    Split,
    /// Everything in APEv2 — byte-for-byte mp3gain behaviour (`-s a`).
    Ape,
    /// Everything in ID3v2 TXXX (`-s i`).
    Id3v2,
}

impl TagLayout {
    /// Whether `REPLAYGAIN_*` goes to ID3v2 TXXX.
    pub fn replaygain_in_id3v2(self) -> bool {
        matches!(self, TagLayout::Split | TagLayout::Id3v2)
    }

    /// Whether `MP3GAIN_UNDO` / `MP3GAIN_MINMAX` go to ID3v2 TXXX.
    pub fn mp3gain_in_id3v2(self) -> bool {
        matches!(self, TagLayout::Id3v2)
    }
}

/// Undo previously-applied gain, auto-dispatching by file format and tag mode.
///
/// AAC files go through the AAC undo path. For MP3 the undo tag is read from
/// wherever `layout` puts it, falling back to the other container so a file
/// tagged under a different layout still rolls back.
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()
    };

    // Issue #306: under the split layout the REPLAYGAIN_* values live in the
    // container that did not hold the undo tag, and they described the
    // pre-undo audio. Both undo paths strip those stale copies inside their
    // own temp-file write, so the rollback and the cleanup of both
    // containers land in one rename.
    if use_id3v2 {
        id3v2::undo_gain_id3v2(file_path)
    } else {
        gain::undo_gain(file_path)
    }
}

/// Delete ReplayGain / undo tags, auto-dispatching by file format and tag mode.
///
/// For AAC, deletes both the ReplayGain and undo freeform tags. For MP3, both
/// containers are cleared under [`TagLayout::Split`] — the point of `-s d` is
/// to leave no gain tags behind, and a split-tagged file has them in two
/// places.
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)
        }
    }
}

/// Tag store a [`StoredGainTags`] snapshot was read from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GainTagSource {
    /// MP4/AAC iTunes freeform tags.
    Aac,
    /// ID3v2 TXXX frames.
    Id3v2,
    /// APEv2 tag. `tag_present` distinguishes a file with no APE tag at all
    /// from one whose APE tag simply carries no mp3gain items.
    Ape { tag_present: bool },
    /// Both containers were read and merged ([`TagLayout::Split`]).
    Split,
}

/// Owned snapshot of the gain tags stored in one file, as returned by
/// [`read_gain_tags_auto`]. `None` means the tag is absent (not an error).
#[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>,
    /// `REPLAYGAIN_ALGORITHM`; only written by the `--rg2` / `--r128` modes,
    /// so `None` on anything measured with mp3gain-compatible ReplayGain 1.0.
    pub algorithm: Option<String>,
    pub undo: Option<String>,
    pub minmax: Option<String>,
    /// APE-only `MP3GAIN_ALBUM_MINMAX`; always `None` for AAC and ID3v2.
    pub album_minmax: Option<String>,
}

impl StoredGainTags {
    /// A snapshot with every tag absent, attributed to `source`.
    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,
        }
    }

    /// `REPLAYGAIN_TRACK_GAIN` parsed to dB, if present and well-formed.
    pub fn track_gain_db(&self) -> Option<f64> {
        self.track_gain.as_deref().and_then(ape::parse_rg_gain)
    }

    /// `REPLAYGAIN_TRACK_PEAK` parsed to a linear peak.
    pub fn track_peak_value(&self) -> Option<f64> {
        self.track_peak.as_deref().and_then(ape::parse_rg_peak)
    }

    /// `REPLAYGAIN_ALBUM_GAIN` parsed to dB.
    pub fn album_gain_db(&self) -> Option<f64> {
        self.album_gain.as_deref().and_then(ape::parse_rg_gain)
    }

    /// `REPLAYGAIN_ALBUM_PEAK` parsed to a linear peak.
    pub fn album_peak_value(&self) -> Option<f64> {
        self.album_peak.as_deref().and_then(ape::parse_rg_peak)
    }

    /// `(gain_db, peak)` from `REPLAYGAIN_TRACK_*` when the stored values can
    /// stand in for a ReplayGain 1.0 analysis (`-s R`, issue #298): both tags
    /// present and parseable, and no `REPLAYGAIN_ALGORITHM` marker, which
    /// would mean BS.1770 values that don't match the RG1 target. The CLI's
    /// `-s R` and the GUI's "Use stored tags" share this rule.
    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()?))
    }

    /// Album variant of [`Self::rg1_track_values`]: additionally requires the
    /// `REPLAYGAIN_ALBUM_*` pair.
    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()?,
        })
    }

    /// True if at least one gain tag is present.
    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()
    }
}

/// Read stored gain tags (ReplayGain plus mp3gain undo/minmax) without
/// modifying the file, auto-dispatching by file format and tag mode.
///
/// Mirrors [`delete_gain_tags_auto`]'s dispatch: AAC files read the MP4
/// freeform tags, MP3 files read whichever container(s) `layout` uses. Under
/// [`TagLayout::Split`] both are read and merged — each field prefers the
/// container it is written to, then falls back to the other, so tags left by
/// mp3gain or by an earlier `-s i` run still show up. The AAC branch is
/// fail-soft (unreadable tags come back empty); ID3v2/APE read errors are
/// propagated.
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,
        })),
    }
}

/// One file's stored RG1 values in album mode, from
/// [`StoredGainTags::rg1_album_values`].
#[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,
}

/// Two stored `REPLAYGAIN_ALBUM_GAIN` values are "the same album" within this
/// many dB: the 6-decimal tag format and mp3gain's own rounding both stay
/// well inside it.
pub const ALBUM_GAIN_TOLERANCE_DB: f64 = 0.05;

/// The shared album gain and the loudest album peak across the members of
/// one album, from each file's stored `(album_gain_db, album_peak)`, or
/// `None` if the set is empty or the gains disagree by more than
/// [`ALBUM_GAIN_TOLERANCE_DB`]. Stored values are residuals of each file's
/// current loudness, so a partial or inconsistent set cannot be mixed with a
/// fresh analysis: any gap means the whole album gets rescanned (`-s R`,
/// issue #298; GUI issue #302).
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))
}

/// Expand directories in `paths` into the supported audio files they contain
/// (recursively), keeping plain file paths as they are and preserving input
/// order. Shared by the CLI's `-R` and the GUI's folder drop / Add Folder, so
/// the two agree on what a directory expands to.
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)
}

/// Read the cumulative *applied* left-channel gain, in steps, without
/// modifying the file — i.e. how much louder the file currently is than its
/// original, which an undo would roll back.
///
/// The on-disk sign convention differs by container (MP3 stores the undo
/// delta, AAC the applied gain — see [`ape::format_undo_value`]), so the MP3
/// values are negated here and the returned number means the same thing for
/// every format. Mirrors [`undo_gain_auto`]'s container dispatch, so the value
/// matches what `undo_gain_auto` would actually reverse. Returns `None` if the
/// tag is absent or unreadable.
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()?;
            // AAC already stores the applied gain.
            return Some(ape::parse_undo_values(undo_tags.undo()).0);
        }
    }
    // MP3 stores the undo delta (the value to re-add to restore the
    // original), so the applied gain is its negation.
    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())
    };
    // Same fallback order as undo_gain_auto, so the reported value matches
    // what an undo would actually roll back.
    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
    }

    /// `read_undo_steps` must report the cumulative *applied* gain, whichever
    /// container the tag came from. MP3 stores the undo delta (the negation of
    /// the applied gain), so a file that had +4 steps applied carries
    /// `-004,-004` and must read back as `+4`.
    ///
    /// The GUI reverses its displayed volume / gain columns by this value
    /// after an undo, so a sign flip here silently doubled the error instead
    /// of cancelling it, and left the cached peak wrong for the next
    /// prevent-clipping check.
    #[test]
    fn read_undo_steps_reports_applied_gain_for_mp3() {
        let mut tag = ape::ApeTag::new();
        // What an apply of +4 steps records.
        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"
            );
        }

        // An attenuating apply reads back negative.
        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));

        // No tag at all stays None rather than reading as 0.
        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)
        }
    }

    /// The `-s R` trust rule shared by the CLI and GUI: both track values
    /// parse, and no BS.1770 marker.
    #[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);
    }

    /// Members must agree on the album gain (within the tolerance) and the
    /// album peak is the loudest member's. A gap or a disagreement means the
    /// whole album gets rescanned.
    #[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;

    /// Regression for issue #149: applying gain to an MP4 file via the
    /// auto-dispatcher must NOT run the MP3 sync-word scanner, which would
    /// overwrite bytes inside MP4 atoms whenever they happen to look like a
    /// valid MPEG L3 frame header and corrupt the container.
    ///
    /// The crafted MP4 below embeds a 72-byte MPEG2.5 L3 8kbps frame header
    /// immediately after the ftyp box. The buggy MP3 path would treat byte 27
    /// (the `global_gain` location inside the side info) as a writable gain
    /// slot and rewrite it. The dispatch must hand the file to the AAC path
    /// (which rejects it cleanly because there's no `mdat`) and leave the
    /// bytes untouched.
    #[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");

        // ftyp box (20 bytes) + two back-to-back MPEG2.5 L3 8kbps/11025Hz stereo
        // "frames" (52 bytes each). The MP3 scanner validates a frame by looking
        // for another sync word at next_pos or by next_pos == audio_end — so two
        // chained frames make the first one parse as valid.
        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' ', // ftyp box (20 bytes, accepted brand)
        ];
        let frame_header = [0xFFu8, 0xE3, 0x10, 0x00];
        bytes.extend_from_slice(&frame_header);
        bytes.resize(20 + 52, 0x55); // pad first frame to 52 bytes
        bytes.extend_from_slice(&frame_header);
        bytes.resize(20 + 52 + 52, 0x55); // pad second frame to 52 bytes
        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);
    }
}