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::gain::{db_to_steps, peak_to_headroom_db, steps_to_db, Channel, GainOptions, MAX_GAIN};
use crate::replaygain::ReplayGainResult;
use crate::{ape, id3v2, mp4meta};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[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>,
}
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,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApplyReport {
pub modified: usize,
pub actual_steps: i32,
pub clipping_prevented: bool,
pub clipping_detected: Option<ClippingDetection>,
}
#[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 {
std::fs::metadata(file_path)
.ok()
.and_then(|m| m.modified().ok())
} else {
None
};
let mut mp3_analysis: Option<crate::Mp3Analysis> = None;
let (actual_steps, clipping_prevented, clipping_detected) =
check_clipping(file_path, opts, is_aac, &mut mp3_analysis)?;
let modified = if is_aac {
apply_aac_bytes(file_path, actual_steps, opts)?
} else if opts.use_id3v2 {
apply_mp3_id3v2_bytes(file_path, actual_steps, opts, &mut mp3_analysis)?
} else {
apply_mp3_ape_bytes(file_path, actual_steps, opts)?
};
if opts.write_replaygain_tags {
if let Some(track) = opts.track_result.as_ref() {
if is_aac {
let mut tags = mp4meta::ReplayGainTags::default();
tags.set_track(track.gain_db(), track.peak());
if let Some(album) = opts.album_info {
tags.set_album(album.album_gain_db, album.album_peak);
}
mp4meta::write_replaygain_tags(file_path, &tags)?;
} else if opts.use_id3v2 {
let rg = id3v2::Id3v2ReplayGain {
track_gain: Some(format!("{:+.2} dB", track.gain_db())),
track_peak: Some(format!("{:.6}", track.peak())),
album_gain: opts
.album_info
.map(|a| format!("{:+.2} dB", a.album_gain_db)),
album_peak: opts.album_info.map(|a| format!("{:.6}", a.album_peak)),
..Default::default()
};
id3v2::write_id3v2_replaygain(file_path, &rg)?;
}
}
}
if let Some(mtime) = original_mtime {
restore_timestamp(file_path, mtime);
}
Ok(ApplyReport {
modified,
actual_steps,
clipping_prevented,
clipping_detected,
})
}
pub fn predict_apply(file_path: &Path, opts: &ApplyOptions) -> Result<ApplyReport> {
let is_aac = mp4meta::is_aac_file(file_path);
let mut mp3_analysis: Option<crate::Mp3Analysis> = None;
let (actual_steps, clipping_prevented, clipping_detected) =
check_clipping(file_path, opts, is_aac, &mut mp3_analysis)?;
Ok(ApplyReport {
modified: 0,
actual_steps,
clipping_prevented,
clipping_detected,
})
}
fn check_clipping(
file_path: &Path,
opts: &ApplyOptions,
is_aac: bool,
mp3_analysis: &mut Option<crate::Mp3Analysis>,
) -> Result<(i32, bool, Option<ClippingDetection>)> {
let steps = opts.steps;
if steps <= 0 || opts.wrap {
return Ok((steps, false, None));
}
if let Some(track) = opts.track_result.as_ref() {
let gain_linear = 10.0_f64.powf(steps_to_db(steps) / 20.0);
let new_peak = track.peak() * gain_linear;
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 = db_to_steps(max_safe_db).max(0);
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));
}
let headroom = if is_aac {
#[cfg(feature = "aac")]
{
aac::analyze_aac_gains(file_path)
.ok()
.map(|a| (MAX_GAIN as i32).saturating_sub(a.max_gain() as i32))
}
#[cfg(not(feature = "aac"))]
{
let _ = file_path;
None
}
} else {
let info = crate::analyze(file_path).ok();
let headroom = info.as_ref().map(|i| i.headroom_steps());
*mp3_analysis = info;
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) -> Result<usize> {
with_temp_file(file_path, opts.use_temp_file, |r, w| {
if opts.write_undo {
aac::apply_aac_gain_with_undo_to_path(r, w, steps)
} else {
aac::apply_aac_gain_to_path(r, w, steps)
}
})
}
#[cfg(not(feature = "aac"))]
fn apply_aac_bytes(_file_path: &Path, _steps: i32, _opts: &ApplyOptions) -> Result<usize> {
Err(Error::FeatureNotAvailable {
feature: "AAC support",
feature_flag: "aac",
})
}
fn apply_mp3_ape_bytes(file_path: &Path, steps: i32, opts: &ApplyOptions) -> Result<usize> {
with_temp_file(file_path, opts.use_temp_file, |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);
}
gain.apply_to_path(r, w)
})
}
fn apply_mp3_id3v2_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
mp3_analysis: &mut Option<crate::Mp3Analysis>,
) -> Result<usize> {
if opts.write_undo && mp3_analysis.is_none() {
*mp3_analysis = crate::analyze(file_path).ok();
}
let modified = with_temp_file(file_path, opts.use_temp_file, |r, w| {
let mut gain = GainOptions::new(steps).wrap(opts.wrap).undo(false);
if let Some(ch) = opts.channel {
gain = gain.channel(ch);
}
gain.apply_to_path(r, w)
})?;
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),
};
write_id3v2_undo_after_apply(
file_path,
delta_left,
delta_right,
opts.wrap,
mp3_analysis.as_ref(),
)?;
}
Ok(modified)
}
fn with_temp_file<F>(file: &Path, use_temp: bool, operation: F) -> Result<usize>
where
F: FnOnce(&Path, &Path) -> Result<usize>,
{
if !use_temp {
return operation(file, file);
}
let parent = file.parent().unwrap_or(Path::new("."));
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_path = parent.join(format!(
".mp3rgain_temp_{}_{}.mp3",
std::process::id(),
counter
));
match operation(file, &temp_path) {
Ok(frames) => {
std::fs::rename(&temp_path, file).map_err(|e| Error::io_write(file, e))?;
Ok(frames)
}
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
Err(e)
}
}
}
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)));
}
fn write_id3v2_undo_after_apply(
file: &Path,
delta_left: i32,
delta_right: i32,
wrap: bool,
analysis: Option<&crate::Mp3Analysis>,
) -> Result<()> {
let existing_rg = id3v2::read_id3v2_replaygain(file).unwrap_or_default();
let (existing_left, existing_right) = ape::parse_undo_values(existing_rg.undo.as_deref());
let owned;
let (min, max) = match analysis {
Some(a) => (a.min_gain(), a.max_gain()),
None => {
owned = crate::analyze(file)?;
(owned.min_gain(), owned.max_gain())
}
};
id3v2::write_id3v2_undo(
file,
existing_left + delta_left,
existing_right + delta_right,
wrap,
min,
max,
)
}