use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::SystemTime;
#[cfg(feature = "aac")]
use crate::aac;
use crate::error::{Error, Result};
use crate::frame::SaturationStats;
use crate::gain::{
apply_gain_to_peak, peak_to_headroom_db, steps_to_db, Channel, GainOptions, GAIN_STEP_DB,
MAX_GAIN,
};
use crate::replaygain::ReplayGainResult;
use crate::{ape, id3v2, mp4meta};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(feature = "aac")]
type AacAnalysisCache = Option<aac::AacAnalysis>;
#[cfg(not(feature = "aac"))]
type AacAnalysisCache = Option<std::convert::Infallible>;
#[derive(Debug, Clone, Copy)]
pub struct AacAlbumInfo {
pub album_gain_db: f64,
pub album_peak: f64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApplyOptions {
pub steps: i32,
pub track_result: Option<ReplayGainResult>,
pub album_info: Option<AacAlbumInfo>,
pub prevent_clipping: bool,
pub wrap: bool,
pub preserve_timestamp: bool,
pub use_temp_file: bool,
pub write_undo: bool,
pub write_replaygain_tags: bool,
pub use_id3v2: bool,
pub channel: Option<Channel>,
pub skip_clipping_check: bool,
}
impl ApplyOptions {
pub fn new(steps: i32) -> Self {
Self {
steps,
track_result: None,
album_info: None,
prevent_clipping: false,
wrap: false,
preserve_timestamp: false,
use_temp_file: false,
write_undo: true,
write_replaygain_tags: false,
use_id3v2: false,
channel: None,
skip_clipping_check: false,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApplyReport {
pub modified: usize,
pub actual_steps: i32,
pub clipping_prevented: bool,
pub clipping_detected: Option<ClippingDetection>,
pub saturated_low: usize,
pub saturated_high: usize,
pub gain_range: Option<(u8, u8)>,
}
#[derive(Debug, Clone, Copy)]
pub enum ClippingDetection {
Headroom(i32),
Peak(f64),
}
pub fn apply_with_options(file_path: &Path, opts: &ApplyOptions) -> Result<ApplyReport> {
let is_aac = mp4meta::is_aac_file(file_path);
if is_aac && opts.channel.is_some() {
return Err(Error::ChannelGainOnAac);
}
let original_mtime = if opts.preserve_timestamp {
read_mtime(file_path)
} else {
None
};
let mut aac_analysis: AacAnalysisCache = None;
let mut mp3_data: Option<Vec<u8>> = None;
let (actual_steps, clipping_prevented, clipping_detected) =
check_clipping(file_path, opts, is_aac, &mut aac_analysis, &mut mp3_data)?;
let mut saturation = SaturationStats::default();
let mut ape_rg_folded = false;
let modified = if is_aac {
apply_aac_bytes(file_path, actual_steps, opts, aac_analysis)?
} else if opts.use_id3v2 {
saturation = apply_mp3_id3v2_bytes(file_path, actual_steps, opts, mp3_data.take())?;
saturation.frames
} else {
let folded_rg = if opts.write_replaygain_tags && opts.channel.is_none() && !opts.wrap {
compute_rg_residual(file_path, opts, actual_steps, false).map(|r| r.to_ape())
} else {
None
};
ape_rg_folded = folded_rg.is_some();
saturation =
apply_mp3_ape_bytes(file_path, actual_steps, opts, folded_rg, mp3_data.take())?;
saturation.frames
};
if opts.write_replaygain_tags && !opts.use_id3v2 {
let needs_reanalysis =
!is_aac && (opts.wrap || saturation.saturated_low > 0 || saturation.saturated_high > 0);
if is_aac {
if let Some(res) = compute_rg_residual(file_path, opts, actual_steps, false) {
let mut tags = mp4meta::ReplayGainTags::default();
tags.set_track(res.track_gain_db, res.track_peak);
if let Some((album_gain, album_peak)) = res.album {
tags.set_album(album_gain, album_peak);
}
mp4meta::write_replaygain_tags(file_path, &tags)?;
}
} else if !ape_rg_folded || needs_reanalysis {
if let Some(res) = compute_rg_residual(file_path, opts, actual_steps, needs_reanalysis)
{
ape::write_ape_replaygain(file_path, &res.to_ape())?;
}
}
}
if let Some(mtime) = original_mtime {
restore_timestamp(file_path, mtime);
}
let gain_range = (!is_aac && opts.channel.is_none() && saturation.frames > 0)
.then_some((saturation.max_gain, saturation.min_gain));
Ok(ApplyReport {
modified,
actual_steps,
clipping_prevented,
clipping_detected,
saturated_low: saturation.saturated_low,
saturated_high: saturation.saturated_high,
gain_range,
})
}
pub fn predict_apply(file_path: &Path, opts: &ApplyOptions) -> Result<ApplyReport> {
let is_aac = mp4meta::is_aac_file(file_path);
let mut aac_analysis: AacAnalysisCache = None;
let (actual_steps, clipping_prevented, clipping_detected) =
check_clipping(file_path, opts, is_aac, &mut aac_analysis, &mut None)?;
Ok(ApplyReport {
modified: 0,
actual_steps,
clipping_prevented,
clipping_detected,
saturated_low: 0,
saturated_high: 0,
gain_range: None,
})
}
fn check_clipping(
file_path: &Path,
opts: &ApplyOptions,
is_aac: bool,
aac_analysis: &mut AacAnalysisCache,
mp3_data: &mut Option<Vec<u8>>,
) -> Result<(i32, bool, Option<ClippingDetection>)> {
let steps = opts.steps;
if opts.wrap || (opts.skip_clipping_check && !opts.prevent_clipping) {
return Ok((steps, false, None));
}
if let Some(track) = opts.track_result.as_ref() {
let new_peak = apply_gain_to_peak(track.peak(), steps_to_db(steps));
if new_peak > 1.0 {
if opts.prevent_clipping {
let max_safe_db = peak_to_headroom_db(track.peak()).unwrap_or(0.0);
let max_safe_steps = (max_safe_db / GAIN_STEP_DB).floor() as i32;
return Ok((
max_safe_steps,
true,
Some(ClippingDetection::Peak(new_peak)),
));
}
return Ok((steps, false, Some(ClippingDetection::Peak(new_peak))));
}
return Ok((steps, false, None));
}
if steps <= 0 {
return Ok((steps, false, None));
}
let headroom = if is_aac {
#[cfg(feature = "aac")]
{
let analysis = aac::analyze_aac_gains(file_path).ok();
let headroom = analysis
.as_ref()
.map(|a| (MAX_GAIN as i32).saturating_sub(a.max_gain() as i32));
*aac_analysis = analysis;
headroom
}
#[cfg(not(feature = "aac"))]
{
let _ = file_path;
let _ = &aac_analysis;
None
}
} else {
let data = std::fs::read(file_path).ok();
let headroom = data
.as_deref()
.and_then(|d| crate::analyze_data(d).ok())
.map(|i| i.headroom_steps());
*mp3_data = data;
headroom
};
if let Some(h) = headroom {
if steps > h {
if opts.prevent_clipping {
return Ok((h, true, Some(ClippingDetection::Headroom(h))));
}
return Ok((steps, false, Some(ClippingDetection::Headroom(h))));
}
}
Ok((steps, false, None))
}
#[cfg(feature = "aac")]
fn apply_aac_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
analysis: AacAnalysisCache,
) -> Result<usize> {
with_temp_file(file_path, |r, w| {
if opts.write_undo {
aac::apply_aac_gain_with_undo_to_path_with_analysis(r, w, steps, analysis)
} else {
aac::apply_aac_gain_to_path_with_analysis(r, w, steps, analysis)
}
})
}
#[cfg(not(feature = "aac"))]
fn apply_aac_bytes(
_file_path: &Path,
_steps: i32,
_opts: &ApplyOptions,
_analysis: AacAnalysisCache,
) -> Result<usize> {
Err(Error::FeatureNotAvailable {
feature: "AAC support",
feature_flag: "aac",
})
}
struct RgResidual {
track_gain_db: f64,
track_peak: f64,
album: Option<(f64, f64)>,
}
impl RgResidual {
fn to_ape(&self) -> ape::ApeReplayGain {
ape::ApeReplayGain {
track_gain: Some(ape::format_rg_gain(self.track_gain_db)),
track_peak: Some(ape::format_rg_peak(self.track_peak)),
album_gain: self.album.map(|(g, _)| ape::format_rg_gain(g)),
album_peak: self.album.map(|(_, p)| ape::format_rg_peak(p)),
}
}
fn to_id3v2(&self) -> id3v2::Id3v2ReplayGain {
let ape = self.to_ape();
id3v2::Id3v2ReplayGain {
track_gain: ape.track_gain,
track_peak: ape.track_peak,
album_gain: ape.album_gain,
album_peak: ape.album_peak,
..Default::default()
}
}
}
fn compute_rg_residual(
modified_path: &Path,
opts: &ApplyOptions,
actual_steps: i32,
reanalyze: bool,
) -> Option<RgResidual> {
let track = opts.track_result.as_ref()?;
let arithmetic = || {
let db = steps_to_db(actual_steps);
(
track.gain_db() - db,
apply_gain_to_peak(track.peak(), db),
db,
)
};
let (track_gain_db, track_peak, applied_db) = if reanalyze {
match crate::replaygain::analyze_track(modified_path) {
Ok(post) => (
post.gain_db(),
post.peak(),
track.gain_db() - post.gain_db(),
),
Err(_) => arithmetic(),
}
} else {
arithmetic()
};
let album = opts.album_info.map(|a| {
(
a.album_gain_db - applied_db,
apply_gain_to_peak(a.album_peak, applied_db),
)
});
Some(RgResidual {
track_gain_db,
track_peak,
album,
})
}
fn apply_mp3_ape_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
replaygain: Option<ape::ApeReplayGain>,
preread: Option<Vec<u8>>,
) -> Result<SaturationStats> {
with_temp_file(file_path, |r, w| {
let mut gain = GainOptions::new(steps)
.wrap(opts.wrap)
.undo(opts.write_undo);
if let Some(ch) = opts.channel {
gain = gain.channel(ch);
}
if let Some(rg) = replaygain {
gain = gain.replaygain(rg);
}
gain.apply_to_path_with_stats_preread(r, w, preread)
})
}
fn apply_mp3_id3v2_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
preread: Option<Vec<u8>>,
) -> Result<SaturationStats> {
with_temp_file(file_path, |r, w| {
let mut gain = GainOptions::new(steps).wrap(opts.wrap).undo(false);
if let Some(ch) = opts.channel {
gain = gain.channel(ch);
}
let stats = gain.apply_to_path_with_stats_preread(r, w, preread)?;
let mut rg = id3v2::Id3v2ReplayGain::default();
if opts.write_undo {
let (delta_left, delta_right) = match opts.channel {
Some(Channel::Left) => (steps, 0),
Some(Channel::Right) => (0, steps),
None => (steps, steps),
};
let existing = id3v2::read_id3v2_replaygain(w).unwrap_or_default();
let (existing_left, existing_right) = ape::parse_undo_values(existing.undo.as_deref());
rg.undo = Some(ape::format_undo_value(
existing_left - delta_left,
existing_right - delta_right,
opts.wrap,
));
let (min, max) = if opts.channel.is_none() && stats.frames > 0 {
(stats.min_gain, stats.max_gain)
} else {
let post = crate::analyze(w)?;
(post.min_gain(), post.max_gain())
};
rg.minmax = Some(ape::format_minmax(min, max));
}
if opts.write_replaygain_tags {
let reanalyze = opts.wrap || stats.saturated_low > 0 || stats.saturated_high > 0;
if let Some(res) = compute_rg_residual(w, opts, steps, reanalyze) {
let values = res.to_id3v2();
rg.track_gain = values.track_gain;
rg.track_peak = values.track_peak;
rg.album_gain = values.album_gain;
rg.album_peak = values.album_peak;
}
}
if rg.undo.is_some() || rg.track_gain.is_some() {
id3v2::write_id3v2_replaygain_direct(w, &rg)?;
}
Ok(stats)
})
}
pub(crate) fn temp_sibling_path(file: &Path, ext: &str) -> std::path::PathBuf {
let parent = file.parent().unwrap_or(Path::new("."));
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
parent.join(format!(
".mp3rgain_temp_{}_{}.{}",
std::process::id(),
counter,
ext
))
}
pub(crate) fn persist_temp(original: &Path, temp: &Path) -> Result<()> {
let finish = || -> std::io::Result<()> {
std::fs::OpenOptions::new()
.write(true)
.open(temp)?
.sync_all()?;
if let Ok(meta) = std::fs::metadata(original) {
std::fs::set_permissions(temp, meta.permissions())?;
}
std::fs::rename(temp, original)
};
finish().map_err(|e| Error::io_write(original, e))
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("tmp");
let temp = temp_sibling_path(path, ext);
let result = std::fs::write(&temp, data)
.map_err(|e| Error::io_write(path, e))
.and_then(|_| persist_temp(path, &temp));
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
fn with_temp_file<T, F>(file: &Path, operation: F) -> Result<T>
where
F: FnOnce(&Path, &Path) -> Result<T>,
{
let temp_path = temp_sibling_path(file, "mp3");
let result = operation(file, &temp_path).and_then(|value| {
persist_temp(file, &temp_path)?;
Ok(value)
});
if result.is_err() {
let _ = std::fs::remove_file(&temp_path);
}
result
}
pub fn restore_timestamp(file: &Path, mtime: SystemTime) {
let _ = std::fs::File::options()
.write(true)
.open(file)
.and_then(|f| f.set_times(std::fs::FileTimes::new().set_modified(mtime)));
}
pub fn read_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
}
pub fn write_album_minmax(files: &[(&Path, Option<(u8, u8)>)]) {
use rayon::prelude::*;
let ranges: Vec<Option<(u8, u8)>> = files
.par_iter()
.map(|&(file, range)| {
range.or_else(|| {
crate::analyze(file)
.ok()
.map(|a| (a.max_gain(), a.min_gain()))
})
})
.collect();
let mut album_min = u8::MAX;
let mut album_max = u8::MIN;
let mut mp3_files: Vec<&Path> = Vec::new();
for (&(file, _), range) in files.iter().zip(&ranges) {
if let Some((max, min)) = *range {
album_min = album_min.min(min);
album_max = album_max.max(max);
mp3_files.push(file);
}
}
mp3_files.par_iter().for_each(|file| {
let _ = ape::write_ape_album_minmax(file, album_min, album_max);
});
}
#[cfg(test)]
#[cfg(feature = "replaygain")]
mod tests {
use super::*;
use crate::replaygain::AudioFileType;
fn track_with_peak(peak: f64) -> ReplayGainResult {
ReplayGainResult::new(0.0, 0.0, peak, 44_100, AudioFileType::Mp3)
}
fn opts_with_track(steps: i32, peak: f64, prevent_clipping: bool) -> ApplyOptions {
let mut opts = ApplyOptions::new(steps);
opts.track_result = Some(track_with_peak(peak));
opts.prevent_clipping = prevent_clipping;
opts
}
#[test]
fn prevent_clipping_caps_at_floor_not_round() {
let opts = opts_with_track(5, 0.9, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(prevented);
assert_eq!(steps, 0);
let new_peak = 0.9 * 10.0_f64.powf(steps_to_db(steps) / 20.0);
assert!(new_peak <= 1.0, "capped output still clips ({new_peak})");
}
#[test]
fn prevent_clipping_never_overshoots_headroom() {
for &peak in &[0.55_f64, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99] {
let opts = opts_with_track(20, peak, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(prevented, "peak {peak} should trigger prevention");
let new_peak = peak * 10.0_f64.powf(steps_to_db(steps) / 20.0);
assert!(
new_peak <= 1.0,
"peak {peak} -> capped steps {steps} still clips ({new_peak})"
);
}
}
#[test]
fn prevent_clipping_passthrough_when_safe() {
let opts = opts_with_track(3, 0.5, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(!prevented);
assert_eq!(steps, 3);
}
#[test]
fn prevent_clipping_returns_negative_for_already_clipping_source() {
let opts = opts_with_track(1, 1.2, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(prevented);
assert!(steps < 0, "expected negative steps, got {steps}");
let new_peak = 1.2 * 10.0_f64.powf(steps_to_db(steps) / 20.0);
assert!(
new_peak <= 1.0,
"capped output still clips ({new_peak}) at steps={steps}"
);
}
#[test]
fn prevent_clipping_caps_zero_step_clipping_track() {
let opts = opts_with_track(0, 1.2, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(prevented);
assert!(steps < 0, "expected attenuation, got {steps}");
let new_peak = 1.2 * 10.0_f64.powf(steps_to_db(steps) / 20.0);
assert!(new_peak <= 1.0, "capped output still clips ({new_peak})");
}
#[test]
fn zero_step_non_clipping_track_is_noop() {
let opts = opts_with_track(0, 0.8, true);
let (steps, prevented, detected) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert_eq!(steps, 0);
assert!(!prevented);
assert!(detected.is_none());
}
#[test]
fn prevent_clipping_never_overshoots_for_clipping_source() {
for &peak in &[1.001_f64, 1.05, 1.1, 1.2, 1.5, 2.0] {
let opts = opts_with_track(5, peak, true);
let (steps, prevented, _) =
check_clipping(Path::new("unused"), &opts, false, &mut None, &mut None).unwrap();
assert!(prevented, "peak {peak} should trigger prevention");
let new_peak = peak * 10.0_f64.powf(steps_to_db(steps) / 20.0);
assert!(
new_peak <= 1.0,
"peak {peak} -> capped steps {steps} still clips ({new_peak})"
);
}
}
}