use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::SystemTime;
#[cfg(feature = "aac")]
use crate::aac;
#[cfg(feature = "aac")]
use crate::adts;
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::{AudioFileType, ReplayGainResult};
use crate::{ape, id3v2, mp4meta, TagLayout};
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,
}
impl From<&crate::replaygain::AlbumGainResult> for AacAlbumInfo {
fn from(album: &crate::replaygain::AlbumGainResult) -> Self {
Self {
album_gain_db: album.album_gain_db(),
album_peak: album.album_peak(),
}
}
}
#[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 write_undo: bool,
pub write_replaygain_tags: bool,
pub tag_layout: TagLayout,
pub channel: Option<Channel>,
pub skip_clipping_check: bool,
pub file_type: Option<AudioFileType>,
}
impl ApplyOptions {
pub fn new(steps: i32) -> Self {
Self {
steps,
track_result: None,
album_info: None,
prevent_clipping: false,
wrap: false,
preserve_timestamp: false,
write_undo: true,
write_replaygain_tags: false,
tag_layout: TagLayout::default(),
channel: None,
skip_clipping_check: false,
file_type: None,
}
}
}
impl ApplyOptions {
fn container(&self, file_path: &Path) -> AudioFileType {
self.file_type
.unwrap_or_else(|| AudioFileType::from_path(file_path))
}
}
#[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> {
apply_with_options_inner(file_path, opts).map_err(|e| e.refine_format(file_path))
}
fn apply_with_options_inner(file_path: &Path, opts: &ApplyOptions) -> Result<ApplyReport> {
let container = opts.container(file_path);
let is_aac = container.is_aac_bitstream();
if is_aac && opts.channel.is_some() {
return Err(Error::ChannelGainOnAac);
}
let original_mtime = read_mtime_if(file_path, opts.preserve_timestamp);
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, container, &mut aac_analysis, &mut mp3_data)?;
let mut saturation = SaturationStats::default();
let mut adts_gain_range = None;
let modified = if container == AudioFileType::Adts {
let (modified, range) = apply_adts_bytes(file_path, actual_steps, opts, aac_analysis)?;
adts_gain_range = range;
modified
} else if is_aac {
let rg = opts
.write_replaygain_tags
.then(|| compute_rg_residual(file_path, opts, actual_steps, false))
.flatten()
.map(|res| res.to_mp4());
apply_aac_bytes(file_path, actual_steps, opts, aac_analysis, rg.as_ref())?
} else if opts.tag_layout.mp3gain_in_id3v2() {
saturation = apply_mp3_id3v2_bytes(file_path, actual_steps, opts, mp3_data.take())?;
saturation.frames
} else {
saturation = apply_mp3_ape_bytes(file_path, actual_steps, opts, mp3_data.take())?;
saturation.frames
};
if let Some(mtime) = original_mtime {
restore_timestamp(file_path, mtime);
}
let gain_range = match container {
AudioFileType::Adts => adts_gain_range,
AudioFileType::Aac => None,
_ => (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> {
predict_apply_inner(file_path, opts).map_err(|e| e.refine_format(file_path))
}
fn predict_apply_inner(file_path: &Path, opts: &ApplyOptions) -> Result<ApplyReport> {
let container = opts.container(file_path);
let mut aac_analysis: AacAnalysisCache = None;
let (actual_steps, clipping_prevented, clipping_detected) =
check_clipping(file_path, opts, container, &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,
container: AudioFileType,
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 container.is_aac_bitstream() {
#[cfg(feature = "aac")]
{
let analysis = if container == AudioFileType::Adts {
adts::analyze_adts_gains(file_path).ok()
} else {
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,
replaygain: Option<&mp4meta::ReplayGainTags>,
) -> 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, replaygain)
} else {
aac::apply_aac_gain_to_path_with_analysis(r, w, steps, analysis, replaygain)
}
})
}
#[cfg(not(feature = "aac"))]
fn apply_aac_bytes(
_file_path: &Path,
_steps: i32,
_opts: &ApplyOptions,
_analysis: AacAnalysisCache,
_replaygain: Option<&mp4meta::ReplayGainTags>,
) -> Result<usize> {
Err(Error::FeatureNotAvailable {
feature: "AAC support",
feature_flag: "aac",
})
}
#[cfg(feature = "aac")]
fn apply_adts_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
analysis: AacAnalysisCache,
) -> Result<(usize, Option<(u8, u8)>)> {
with_temp_file(file_path, |r, w| {
let outcome = adts::apply_adts_gain(r, w, steps, analysis)?;
let mut tag = id3v2::read_tag(w)?;
let mut rg = id3v2::Id3v2ReplayGain::default();
if opts.write_undo && steps != 0 {
let existing = id3v2::get_txxx(&tag, ape::TAG_MP3GAIN_UNDO);
let (existing_left, existing_right) = ape::parse_undo_values(existing.as_deref());
let undo = ape::format_undo_value(existing_left - steps, existing_right - steps, false);
rg.undo = Some(undo);
if let Some((max, min)) = outcome.gain_range {
rg.minmax = Some(ape::format_minmax(min, max));
}
}
if opts.write_replaygain_tags {
if let Some(res) = compute_rg_residual(w, opts, steps, false) {
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;
rg.algorithm = values.algorithm;
}
}
if rg.undo.is_some() || rg.track_gain.is_some() {
id3v2::write_rg_frames_direct(w, &mut tag, &rg)?;
}
Ok((outcome.modified, outcome.gain_range))
})
}
#[cfg(not(feature = "aac"))]
fn apply_adts_bytes(
_file_path: &Path,
_steps: i32,
_opts: &ApplyOptions,
_analysis: AacAnalysisCache,
) -> Result<(usize, Option<(u8, u8)>)> {
Err(Error::FeatureNotAvailable {
feature: "AAC support",
feature_flag: "aac",
})
}
struct RgResidual {
track_gain_db: f64,
track_peak: f64,
album: Option<(f64, f64)>,
mode: crate::replaygain::AnalysisMode,
}
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)),
algorithm: self.mode.algorithm_tag().map(str::to_string),
}
}
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,
algorithm: ape.algorithm,
..Default::default()
}
}
fn to_mp4(&self) -> mp4meta::ReplayGainTags {
let mut tags = mp4meta::ReplayGainTags::default();
tags.set_track(self.track_gain_db, self.track_peak);
if let Some((album_gain, album_peak)) = self.album {
tags.set_album(album_gain, album_peak);
}
tags.set_algorithm(self.mode);
tags
}
}
fn compute_rg_residual(
modified_path: &Path,
opts: &ApplyOptions,
actual_steps: i32,
reanalyze: bool,
) -> Option<RgResidual> {
let track = opts.track_result.as_ref()?;
let mode = track.analysis_mode();
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 {
let reopts = crate::replaygain::TrackAnalysisOptions {
mode,
true_peak: track.is_true_peak(),
..Default::default()
};
match crate::replaygain::analyze_track_with_options(modified_path, &reopts) {
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,
mode,
})
}
fn apply_mp3_ape_bytes(
file_path: &Path,
steps: i32,
opts: &ApplyOptions,
preread: Option<Vec<u8>>,
) -> Result<SaturationStats> {
let write_rg = opts.write_replaygain_tags && opts.track_result.is_some();
let folded_rg =
if write_rg && opts.tag_layout == TagLayout::Ape && opts.channel.is_none() && !opts.wrap {
compute_rg_residual(file_path, opts, steps, false).map(|r| r.to_ape())
} else {
None
};
let ape_rg_folded = folded_rg.is_some();
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) = folded_rg {
gain = gain.replaygain(rg);
}
let stats = gain.apply_to_path_with_stats_preread(r, w, preread)?;
if write_rg {
let reanalyze = opts.wrap || stats.saturated_low > 0 || stats.saturated_high > 0;
if opts.tag_layout == TagLayout::Split {
if let Some(res) = compute_rg_residual(w, opts, steps, reanalyze) {
id3v2::write_id3v2_replaygain_direct(w, &res.to_id3v2())?;
}
ape::remove_ape_replaygain(w)?;
} else if !ape_rg_folded || reanalyze {
if let Some(res) = compute_rg_residual(w, opts, steps, reanalyze) {
ape::write_ape_replaygain(w, &res.to_ape())?;
}
}
}
Ok(stats)
})
}
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 tag = id3v2::read_tag(w)?;
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_undo = id3v2::get_txxx(&tag, ape::TAG_MP3GAIN_UNDO);
let (existing_left, existing_right) = ape::parse_undo_values(existing_undo.as_deref());
let (undo_left, undo_right) =
(existing_left - delta_left, existing_right - delta_right);
if (undo_left, undo_right) != (0, 0) || existing_undo.is_some() {
rg.undo = Some(ape::format_undo_value(undo_left, undo_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;
rg.algorithm = values.algorithm;
}
}
if rg.undo.is_some() || rg.track_gain.is_some() {
id3v2::write_rg_frames_direct(w, &mut tag, &rg)?;
}
Ok(stats)
})
}
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
))
}
#[cfg(windows)]
fn retry_sharing_violation<T>(mut op: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
let mut delay = std::time::Duration::from_millis(10);
for _ in 0..8 {
match op() {
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(33)) => {
std::thread::sleep(delay);
delay *= 2;
}
other => return other,
}
}
op()
}
#[cfg(not(windows))]
fn retry_sharing_violation<T>(mut op: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
op()
}
fn persist_temp(original: &Path, temp: &Path) -> Result<()> {
let finish = || -> std::io::Result<()> {
retry_sharing_violation(|| {
std::fs::OpenOptions::new()
.write(true)
.open(temp)?
.sync_all()
})?;
if let Ok(meta) = std::fs::metadata(original) {
retry_sharing_violation(|| std::fs::set_permissions(temp, meta.permissions()))?;
}
retry_sharing_violation(|| std::fs::rename(temp, original))
};
finish().map_err(|e| Error::io_write(original, e))
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
with_temp_file(path, |original, temp| {
std::fs::write(temp, data).map_err(|e| Error::io_write(original, e))
})
}
pub(crate) fn with_temp_file<T, F>(file: &Path, operation: F) -> Result<T>
where
F: FnOnce(&Path, &Path) -> Result<T>,
{
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("tmp");
let temp_path = temp_sibling_path(file, ext);
let result = operation(file, &temp_path).and_then(|value| {
persist_temp(file, &temp_path)?;
Ok(value)
});
if result.is_err() {
let _ = retry_sharing_violation(|| 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 read_mtime_if(path: &Path, preserve: bool) -> Option<SystemTime> {
if preserve {
read_mtime(path)
} else {
None
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TagsOnlyOptions {
pub track_gain_db: f64,
pub track_peak: f64,
pub album: Option<(f64, f64)>,
pub mode: crate::replaygain::AnalysisMode,
pub tag_layout: TagLayout,
pub preserve_timestamp: bool,
}
impl TagsOnlyOptions {
pub fn new(track_gain_db: f64, track_peak: f64, mode: crate::replaygain::AnalysisMode) -> Self {
Self {
track_gain_db,
track_peak,
album: None,
mode,
tag_layout: TagLayout::default(),
preserve_timestamp: false,
}
}
}
pub fn write_replaygain_tags_only(file_path: &Path, opts: &TagsOnlyOptions) -> Result<()> {
let original_mtime = read_mtime_if(file_path, opts.preserve_timestamp);
let values = RgResidual {
track_gain_db: opts.track_gain_db,
track_peak: opts.track_peak,
album: opts.album,
mode: opts.mode,
};
match AudioFileType::from_path(file_path) {
AudioFileType::Aac => mp4meta::write_replaygain_tags(file_path, &values.to_mp4())?,
AudioFileType::Adts => id3v2::write_id3v2_replaygain(file_path, &values.to_id3v2())?,
_ => match opts.tag_layout {
TagLayout::Split => {
id3v2::write_id3v2_replaygain(file_path, &values.to_id3v2())?;
ape::remove_ape_replaygain(file_path)?;
}
TagLayout::Id3v2 => id3v2::write_id3v2_replaygain(file_path, &values.to_id3v2())?,
TagLayout::Ape => ape::write_ape_replaygain(file_path, &values.to_ape())?,
},
}
if let Some(mtime) = original_mtime {
restore_timestamp(file_path, mtime);
}
Ok(())
}
pub fn write_album_minmax(files: &[(&Path, Option<(u8, u8)>)]) {
use rayon::prelude::*;
let files: Vec<(&Path, Option<(u8, u8)>)> = files
.iter()
.copied()
.filter(|&(file, _)| AudioFileType::from_path(file) == AudioFileType::Mp3)
.collect();
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,
Default::default(),
)
}
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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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,
AudioFileType::Mp3,
&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})"
);
}
}
}