mp3rgain 3.7.0

Lossless MP3 volume adjustment - a modern mp3gain replacement written in Rust
Documentation
use anyhow::Result;
use colored::*;
use indicatif::ProgressBar;
use mp3rgain::replaygain::{self, ReplayGainResult};
use mp3rgain::{analyze, mp4meta, peak_to_pcm_sample};
use std::fmt::Write as _;
use std::path::Path;

use crate::cli::options::{Options, OutputFormat};
use crate::json_output::{FileStatus, JsonFileResult};
use crate::processors::utils::{analyze_track, report_unsupported_format};
use crate::util::{get_filename, get_path};

/// Scan the file's global_gain range for an info row, as `(max, min)`.
///
/// `mp3rgain::gain_range` dispatches on the container, so AAC files report the
/// same values `-x` does (issue #329: the MP3-only scanner failed on AAC, so
/// every AAC file took the fallback branch).
///
/// `None` means the file could not be scanned at all — a corrupt stream, or a
/// container none of the scanners handles. It prints as `-`; the fallback used
/// to be (255, 0), the pair `analyze_data` starts its accumulators at, which
/// reads as a real full-range measurement. Raw ADTS `.aac` was the case that
/// surfaced this and now scans like any other AAC stream (issue #330).
pub fn scan_gain_range_for_row(file: &Path) -> Option<(u8, u8)> {
    mp3rgain::gain_range(file).ok().map(|(min, max)| (max, min))
}

/// The two global_gain columns as they are printed: the measured values, or
/// `-` for a file that could not be scanned, the same way the M4A rows below
/// mark a column that does not apply.
pub fn gain_range_fields(gain_range: Option<(u8, u8)>) -> (String, String) {
    match gain_range {
        Some((max, min)) => (max.to_string(), min.to_string()),
        None => ("-".to_string(), "-".to_string()),
    }
}

/// One mp3gain-compatible TSV row from a ReplayGain analysis result.
/// Shared by the info command and the gain-applying commands (`-r`, `-a`),
/// which emit the recommended change before touching the frames.
pub fn tsv_rg_row(
    file: &Path,
    opts: &Options,
    rg_result: &ReplayGainResult,
    gain_range: Option<(u8, u8)>,
) -> String {
    let (gain_steps, gain_db) = opts.modified_gain(rg_result.gain_steps(), rg_result.gain_db());
    let (max_gain, min_gain) = gain_range_fields(gain_range);
    format!(
        "{}\t{}\t{:.6}\t{:.6}\t{}\t{}\n",
        get_path(file),
        gain_steps,
        gain_db,
        opts.tsv_peak(rg_result.peak()),
        max_gain,
        min_gain
    )
}

/// Format one mp3gain-compatible per-file row from a ReplayGain analysis
/// result. Shared by the per-file path below and the single-pass album flow
/// in `cmd_info`, which pre-computes `gain_range` in parallel instead of
/// re-scanning each file inside its sequential emit loop.
pub fn format_rg_row(
    file: &Path,
    opts: &Options,
    rg_result: &ReplayGainResult,
    gain_range: Option<(u8, u8)>,
) -> Result<(JsonFileResult, String)> {
    let mut out = String::new();
    let filename = get_filename(file);

    // Reuse the ReplayGain peak instead of re-decoding the audio
    // via find_max_amplitude (issue #135).
    let max_amp = rg_result.peak();
    let (max_gain_field, min_gain_field) = gain_range_fields(gain_range);

    // Gain with the -m / -d modifiers folded in, the same way the apply
    // paths report it.
    let (gain_steps, gain_db) = opts.modified_gain(rg_result.gain_steps(), rg_result.gain_db());

    // Max Amplitude scaled to 32768 (mp3gain format for beets)
    // beets divides by 32768, so we output peak * 32768
    let max_amplitude_scaled = peak_to_pcm_sample(rg_result.peak());

    match opts.output_format {
        OutputFormat::Tsv => {
            out.push_str(&tsv_rg_row(file, opts, rg_result, gain_range));
        }
        OutputFormat::Text => {
            if !opts.quiet {
                writeln!(out, "{}", filename.cyan().bold())?;
                writeln!(out, "  Recommended \"Track\" dB change: {:.6}", gain_db)?;
                writeln!(
                    out,
                    "  Recommended \"Track\" mp3 gain change: {}",
                    gain_steps
                )?;
                writeln!(
                    out,
                    "  Max PCM sample at current gain: {:.6}",
                    max_amplitude_scaled
                )?;
                writeln!(out, "  Max mp3 global gain field: {}", max_gain_field)?;
                writeln!(out, "  Min mp3 global gain field: {}", min_gain_field)?;
                writeln!(out)?;
            }
        }
        OutputFormat::Json => {}
    }

    Ok((
        JsonFileResult {
            gain_applied_db: Some(gain_db),
            gain_applied_steps: Some(gain_steps),
            max_amplitude: Some(max_amp),
            max_gain: gain_range.map(|(max, _)| max),
            min_gain: gain_range.map(|(_, min)| min),
            ..JsonFileResult::from_analysis(file, rg_result)
        },
        out,
    ))
}

pub fn process_info(
    file: &Path,
    opts: &Options,
    analysis_pb: Option<&ProgressBar>,
) -> Result<(JsonFileResult, String)> {
    let mut out = String::new();
    let result = process_info_into(file, opts, analysis_pb, &mut out)?;
    Ok((result, out))
}

fn process_info_into(
    file: &Path,
    opts: &Options,
    analysis_pb: Option<&ProgressBar>,
    out: &mut String,
) -> Result<JsonFileResult> {
    let filename = get_filename(file);

    // Perform ReplayGain analysis for TSV/Text output (mp3gain compatible)
    if matches!(opts.output_format, OutputFormat::Tsv | OutputFormat::Text)
        && replaygain::is_available()
    {
        match analyze_track(file, opts, analysis_pb) {
            Ok(rg_result) => {
                let (result, text) =
                    format_rg_row(file, opts, &rg_result, scan_gain_range_for_row(file))?;
                out.push_str(&text);
                return Ok(result);
            }
            Err(e) if e.is_unsupported_format() => {
                // Not a failure: a format mp3rgain cannot adjust is reported
                // as a skip so it does not set the exit code (issue #330).
                return Ok(report_unsupported_format(
                    file,
                    filename,
                    &e.to_string(),
                    opts,
                ));
            }
            Err(e) => {
                eprintln!("{} - {}", filename.red(), e);
                return Ok(JsonFileResult::error(file, e));
            }
        }
    }

    // Check if this is an M4A/AAC file - if so, show appropriate message
    if mp4meta::is_mp4_file(file) {
        let codec = mp4meta::detect_mp4_audio_codec(file);
        let is_alac = matches!(codec, Some(mp4meta::Mp4AudioCodec::Alac));
        let format_str = if is_alac { "M4A/ALAC" } else { "M4A/AAC" };

        match opts.output_format {
            OutputFormat::Text => {
                if opts.quiet {
                    writeln!(out, "{}\t{}\t-\t-\t-\t-\t-", filename, format_str)?;
                } else {
                    writeln!(out, "{}", filename.cyan().bold())?;
                    writeln!(out, "  Format:      {}", format_str)?;
                    if is_alac {
                        writeln!(
                            out,
                            "  {}",
                            "ALAC files are not supported for gain adjustment".yellow()
                        )?;
                    } else {
                        writeln!(
                            out,
                            "  {}",
                            "Note: Use -r or -a for ReplayGain analysis".yellow()
                        )?;
                    }
                    writeln!(out)?;
                }
            }
            OutputFormat::Tsv => {
                writeln!(out, "{}\t-\t-\t-\t-\t-", get_path(file))?;
            }
            OutputFormat::Json => {}
        }

        return Ok(JsonFileResult {
            file: file.display().to_string(),
            status: Some(FileStatus::Info),
            ..Default::default()
        });
    }

    // MP3 file: use basic analysis
    match analyze(file) {
        Ok(info) => {
            match opts.output_format {
                OutputFormat::Text => {
                    if opts.quiet {
                        // Quiet mode: tab-separated output
                        writeln!(
                            out,
                            "{}\t{}\t{}\t{}\t{:.1}\t{}\t{:.1}",
                            filename,
                            info.frame_count(),
                            info.min_gain(),
                            info.max_gain(),
                            info.avg_gain(),
                            info.headroom_steps(),
                            info.headroom_db()
                        )?;
                    } else {
                        writeln!(out, "{}", filename.cyan().bold())?;
                        writeln!(
                            out,
                            "  Format:      {} Layer III, {}",
                            info.mpeg_version(),
                            info.channel_mode()
                        )?;
                        writeln!(out, "  Frames:      {}", info.frame_count())?;
                        writeln!(
                            out,
                            "  Gain range:  {} - {} (avg: {:.1})",
                            info.min_gain(),
                            info.max_gain(),
                            info.avg_gain()
                        )?;
                        writeln!(
                            out,
                            "  Headroom:    {} steps ({:+.1} dB)",
                            info.headroom_steps().to_string().green(),
                            info.headroom_db()
                        )?;
                        writeln!(out)?;
                    }
                }
                OutputFormat::Tsv => {
                    // Fallback TSV (ReplayGain not available): basic info
                    writeln!(
                        out,
                        "{}\t{}\t{:.1}\t{:.6}\t{}\t{}",
                        get_path(file),
                        info.headroom_steps(),
                        info.headroom_db(),
                        1.0,
                        info.max_gain(),
                        info.min_gain()
                    )?;
                }
                OutputFormat::Json => {}
            }

            Ok(JsonFileResult {
                file: file.display().to_string(),
                mpeg_version: Some(info.mpeg_version().to_string()),
                channel_mode: Some(info.channel_mode().to_string()),
                frames: Some(info.frame_count()),
                min_gain: Some(info.min_gain()),
                max_gain: Some(info.max_gain()),
                avg_gain: Some(info.avg_gain()),
                headroom_steps: Some(info.headroom_steps()),
                headroom_db: Some(info.headroom_db()),
                ..Default::default()
            })
        }
        Err(e) => {
            if opts.output_format != OutputFormat::Json {
                eprintln!("{} - {}", filename.red(), e);
            }

            Ok(JsonFileResult::error(file, e))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::gain_range_fields;

    /// Issue #329: an unscannable file must not print (255, 0), the pair the
    /// gain accumulators start at, as if it were a measurement.
    #[test]
    fn an_unscannable_range_prints_as_dashes() {
        assert_eq!(
            gain_range_fields(None),
            ("-".to_string(), "-".to_string()),
            "an unscannable file must not report a measured range"
        );
        assert_eq!(
            gain_range_fields(Some((181, 110))),
            ("181".to_string(), "110".to_string())
        );
    }
}