pub mod analyse;
pub mod bitstream;
pub mod breath;
pub mod click;
pub mod consistency;
pub mod crossfade;
pub mod dc_offset;
pub mod deess;
pub mod denoise;
pub mod eq;
pub mod error;
pub mod gate;
pub mod limiter;
pub mod loudness_preset;
pub mod lufs;
pub mod multiband;
pub mod normalise;
pub mod pause_norm;
pub mod plosive;
pub mod room_tone;
pub mod spectral;
pub mod temporal;
pub use analyse::{AcxReport, analyse};
pub use bitstream::{CbrReport, Id3Report, check_cbr, check_id3_tags};
pub use consistency::{ConsistencyReport, consistency_check};
pub use crossfade::crossfade;
pub use error::AcxError;
pub use loudness_preset::LoudnessPreset;
pub use lufs::{LufsReport, integrated_lufs, loudness_range};
pub use multiband::MultibandParams;
pub use spectral::{SpectralViolation, SpectralViolationKind, scan as spectral_scan};
pub use temporal::{
DeadAirViolation, check_bookends, count_digital_zero_runs, detect_dead_air, max_dead_air,
};
#[derive(Debug, Clone)]
pub struct AcxConfig {
pub rms_target_db: f32,
pub rms_min_db: f32,
pub rms_max_db: f32,
pub peak_ceiling_db: f32,
pub noise_floor_max_db: f32,
pub silence_threshold_db: f32,
pub room_tone_db: f32,
pub dead_air_limit: time::Duration,
pub sibilance_ratio_threshold: f32,
pub plosive_ratio_threshold: f32,
pub click_suppression_enabled: bool,
pub denoise_enabled: bool,
pub denoise_profile_ms: u32,
pub denoise_oversubtraction: f32,
pub denoise_spectral_floor: f32,
pub eq_enabled: bool,
pub eq_low_shelf_db: f32,
pub eq_high_shelf_db: f32,
pub deess_enabled: bool,
pub deess_threshold_ratio: f32,
pub deess_max_reduction_db: f32,
pub plosive_suppression_enabled: bool,
pub plosive_attenuation_db: f32,
pub multiband_enabled: bool,
pub breath_removal_enabled: bool,
pub pause_norm_enabled: bool,
pub pause_sentence_target_ms: u32,
pub pause_paragraph_target_ms: u32,
pub pause_scene_target_ms: u32,
}
impl Default for AcxConfig {
fn default() -> Self {
Self {
rms_target_db: -20.5,
rms_min_db: -23.0,
rms_max_db: -18.0,
peak_ceiling_db: -3.0,
noise_floor_max_db: -60.0,
silence_threshold_db: -65.0,
room_tone_db: -62.0, dead_air_limit: temporal::DEAD_AIR_LIMIT,
sibilance_ratio_threshold: spectral::SIBILANCE_RATIO_THRESHOLD,
plosive_ratio_threshold: spectral::PLOSIVE_RATIO_THRESHOLD,
click_suppression_enabled: true,
denoise_enabled: false,
denoise_profile_ms: denoise::DEFAULT_PROFILE_MS,
denoise_oversubtraction: denoise::DEFAULT_OVERSUBTRACTION,
denoise_spectral_floor: denoise::DEFAULT_SPECTRAL_FLOOR,
eq_enabled: true,
eq_low_shelf_db: eq::DEFAULT_LOW_SHELF_DB,
eq_high_shelf_db: eq::DEFAULT_HIGH_SHELF_DB,
deess_enabled: true,
deess_threshold_ratio: deess::DEFAULT_THRESHOLD_RATIO,
deess_max_reduction_db: deess::DEFAULT_MAX_REDUCTION_DB,
plosive_suppression_enabled: true,
plosive_attenuation_db: plosive::DEFAULT_ATTENUATION_DB,
multiband_enabled: true,
breath_removal_enabled: false,
pause_norm_enabled: true,
pause_sentence_target_ms: pause_norm::DEFAULT_SENTENCE_TARGET_MS,
pause_paragraph_target_ms: pause_norm::DEFAULT_PARAGRAPH_TARGET_MS,
pause_scene_target_ms: pause_norm::DEFAULT_SCENE_TARGET_MS,
}
}
}
#[derive(Debug, Clone)]
pub struct DiagnosticReport {
pub rms_db: f32,
pub peak_db: f32,
pub noise_floor_db: f32,
pub acx_compliant: bool,
pub dc_offset: f32,
pub has_dc_offset: bool,
pub spectral_violations: Vec<SpectralViolation>,
pub dead_air_violations: Vec<DeadAirViolation>,
pub head_ok: bool,
pub tail_ok: bool,
pub digital_zero_runs: usize,
pub integrated_lufs: f32,
pub loudness_range: f32,
}
pub fn validate(pcm_bytes: &[u8], sample_rate: u32) -> Result<DiagnosticReport, AcxError> {
validate_with_config(pcm_bytes, sample_rate, &AcxConfig::default())
}
pub fn validate_with_config(
pcm_bytes: &[u8],
sample_rate: u32,
cfg: &AcxConfig,
) -> Result<DiagnosticReport, AcxError> {
if pcm_bytes.is_empty() {
return Err(AcxError::EmptyInput);
}
let samples = bytes_to_samples(pcm_bytes)?;
let acx = analyse::analyse(&samples, sample_rate, cfg);
let dc = dc_offset::measure(&samples);
let spectral_violations = spectral::scan(&samples, sample_rate);
let dead_air_violations =
temporal::detect_dead_air(&samples, sample_rate, cfg.silence_threshold_db);
let (head_ok, tail_ok) = temporal::check_bookends(&samples, sample_rate);
let digital_zero_runs = temporal::count_digital_zero_runs(&samples);
let il = lufs::integrated_lufs(&samples, sample_rate);
let lr = lufs::loudness_range(&samples, sample_rate);
Ok(DiagnosticReport {
rms_db: acx.rms_db,
peak_db: acx.peak_db,
noise_floor_db: acx.noise_floor_db,
acx_compliant: acx.compliant,
dc_offset: dc,
has_dc_offset: dc_offset::has_offset(&samples),
spectral_violations,
dead_air_violations,
head_ok,
tail_ok,
digital_zero_runs,
integrated_lufs: il,
loudness_range: lr,
})
}
pub fn bytes_to_samples(bytes: &[u8]) -> Result<Vec<i16>, AcxError> {
if bytes.len() % 2 != 0 {
return Err(AcxError::OddByteLength);
}
Ok(bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect())
}
pub fn samples_to_bytes(samples: &[i16]) -> Vec<u8> {
samples.iter().flat_map(|&s| s.to_le_bytes()).collect()
}
pub fn process(pcm_bytes: &[u8], sample_rate: u32) -> Result<Vec<u8>, AcxError> {
process_with_config(pcm_bytes, sample_rate, &AcxConfig::default())
}
pub fn process_with_config(
pcm_bytes: &[u8],
sample_rate: u32,
cfg: &AcxConfig,
) -> Result<Vec<u8>, AcxError> {
if pcm_bytes.is_empty() {
return Err(AcxError::EmptyInput);
}
let mut samples = bytes_to_samples(pcm_bytes)?;
if cfg.click_suppression_enabled {
click::suppress_clicks(&mut samples, sample_rate);
}
if dc_offset::has_offset(&samples) {
dc_offset::remove(&mut samples);
}
if cfg.denoise_enabled {
denoise::denoise_with_params(
&mut samples,
sample_rate,
cfg.denoise_profile_ms,
cfg.denoise_oversubtraction,
cfg.denoise_spectral_floor,
);
}
if cfg.eq_enabled {
eq::apply_warmth_with_params(
&mut samples,
sample_rate,
cfg.eq_low_shelf_db,
eq::DEFAULT_LOW_SHELF_HZ,
cfg.eq_high_shelf_db,
eq::DEFAULT_HIGH_SHELF_HZ,
);
}
if cfg.deess_enabled {
deess::deess_with_params(
&mut samples,
sample_rate,
cfg.deess_threshold_ratio,
cfg.deess_max_reduction_db,
);
}
if cfg.plosive_suppression_enabled {
plosive::suppress_plosives_with_attenuation(
&mut samples,
sample_rate,
cfg.plosive_attenuation_db,
);
}
if cfg.multiband_enabled {
multiband::compress(&mut samples, sample_rate);
}
let normalise_target = {
let head_s =
(sample_rate as usize * temporal::HEAD_DURATION.whole_milliseconds() as usize) / 1000;
let tail_s =
(sample_rate as usize * temporal::TAIL_DURATION.whole_milliseconds() as usize) / 1000;
let speech_start = head_s.min(samples.len());
let speech_end = samples.len().saturating_sub(tail_s).max(speech_start);
if speech_start < speech_end {
let overall_rms_db = analyse::rms_db(&samples);
let middle_rms_db = analyse::rms_db(&samples[speech_start..speech_end]);
let s = samples.len() as f32;
let b = (head_s + tail_s).min(samples.len()) as f32;
cfg.rms_target_db + 10.0 * (s / (s - b)).log10() + (overall_rms_db - middle_rms_db)
} else {
cfg.rms_target_db
}
};
normalise::normalise(&mut samples, normalise_target);
limiter::limit(&mut samples, sample_rate, cfg.peak_ceiling_db);
if cfg.breath_removal_enabled {
breath::remove_breaths(&mut samples, sample_rate, cfg.room_tone_db);
}
if cfg.pause_norm_enabled {
samples = pause_norm::normalize_pauses_with_targets(
&samples,
sample_rate,
cfg.pause_sentence_target_ms,
cfg.pause_paragraph_target_ms,
cfg.pause_scene_target_ms,
);
}
let tone_samples = sample_rate as usize / 2; let tone = room_tone::generate_room_tone(tone_samples, cfg.room_tone_db);
gate::gate_to_room_tone(&mut samples, sample_rate, cfg.silence_threshold_db, &tone);
gate::pad_bookends(&mut samples, sample_rate, &tone);
let report = analyse::analyse(&samples, sample_rate, cfg);
if !report.compliant {
return Err(AcxError::StillNonCompliant {
rms_db: report.rms_db,
rms_min: cfg.rms_min_db,
rms_max: cfg.rms_max_db,
peak_db: report.peak_db,
peak_ceiling: cfg.peak_ceiling_db,
noise_floor_db: report.noise_floor_db,
noise_floor_max: cfg.noise_floor_max_db,
});
}
Ok(samples_to_bytes(&samples))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_RATE: u32 = 24_000;
fn sine_wave(freq_hz: f32, duration_secs: f32, amplitude: f32, sample_rate: u32) -> Vec<i16> {
let n = (sample_rate as f32 * duration_secs) as usize;
(0..n)
.map(|i| {
let t = i as f32 / sample_rate as f32;
let v = amplitude * (2.0 * std::f32::consts::PI * freq_hz * t).sin();
v.clamp(i16::MIN as f32, i16::MAX as f32) as i16
})
.collect()
}
fn to_bytes(samples: &[i16]) -> Vec<u8> {
samples_to_bytes(samples)
}
fn speech_like(amplitude: f32, total_secs: f32, sample_rate: u32) -> Vec<i16> {
let speech_ms = 300usize;
let pause_ms = 50usize;
let period_samples = (sample_rate as usize * (speech_ms + pause_ms)) / 1000;
let total_samples = (sample_rate as f32 * total_secs) as usize;
let speech_samples = (sample_rate as usize * speech_ms) / 1000;
let mut out = Vec::with_capacity(total_samples);
let mut t = 0usize;
while out.len() < total_samples {
let pos = t % period_samples;
if pos < speech_samples {
let sine_t = pos as f32 / sample_rate as f32;
let v = amplitude * (2.0 * std::f32::consts::PI * 440.0 * sine_t).sin();
out.push(v.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
} else {
out.push(0i16);
}
t += 1;
}
out.truncate(total_samples);
out
}
#[test]
fn normalise_brings_quiet_track_into_window() {
let samples = speech_like(1000.0, 10.0, SAMPLE_RATE);
let bytes = to_bytes(&samples);
let out = process(&bytes, SAMPLE_RATE).unwrap();
let out_samples = bytes_to_samples(&out).unwrap();
let report = analyse::analyse(&out_samples, SAMPLE_RATE, &AcxConfig::default());
assert!(
report.rms_db >= -23.0 && report.rms_db <= -18.0,
"RMS out of ACX window: {:.1} dB",
report.rms_db
);
}
#[test]
fn limiter_prevents_clipping() {
let samples = speech_like(i16::MAX as f32 * 0.99, 10.0, SAMPLE_RATE);
let bytes = to_bytes(&samples);
let out = process(&bytes, SAMPLE_RATE).unwrap();
let out_samples = bytes_to_samples(&out).unwrap();
let report = analyse::analyse(&out_samples, SAMPLE_RATE, &AcxConfig::default());
assert!(
report.peak_db <= -3.0,
"Peak exceeded ACX ceiling: {:.1} dB",
report.peak_db
);
}
#[test]
fn gate_replaces_digital_silence() {
let mut samples = vec![0i16; SAMPLE_RATE as usize];
samples.extend(sine_wave(440.0, 8.0, 3000.0, SAMPLE_RATE));
let bytes = to_bytes(&samples);
let out = process(&bytes, SAMPLE_RATE).unwrap();
let out_samples = bytes_to_samples(&out).unwrap();
let silent_half = &out_samples[..SAMPLE_RATE as usize];
let floor = analyse::noise_floor_db(silent_half, SAMPLE_RATE);
assert!(floor > -144.0, "Gate did not replace digital silence");
}
#[test]
fn odd_byte_length_returns_error() {
let bytes = vec![0u8; 101];
assert!(matches!(
process(&bytes, SAMPLE_RATE),
Err(AcxError::OddByteLength)
));
}
#[test]
fn empty_input_returns_error() {
assert!(matches!(
process(&[], SAMPLE_RATE),
Err(AcxError::EmptyInput)
));
}
#[test]
fn room_tone_hits_target_db() {
let tone = room_tone::generate_room_tone(SAMPLE_RATE as usize, -62.0);
let measured = analyse::rms_db(&tone);
assert!(
(measured - (-62.0)).abs() < 1.5,
"Room tone RMS {:.1} dB too far from −62 dB",
measured
);
}
#[test]
fn analyse_report_is_accurate() {
let samples = sine_wave(440.0, 2.0, i16::MAX as f32, SAMPLE_RATE);
let cfg = AcxConfig::default();
let report = analyse::analyse(&samples, SAMPLE_RATE, &cfg);
assert!(!report.compliant);
assert!(report.peak_db > cfg.peak_ceiling_db);
}
#[test]
fn validate_detects_dc_offset() {
let samples: Vec<i16> = speech_like(2000.0, 2.0, SAMPLE_RATE)
.into_iter()
.map(|s| s.saturating_add(1000))
.collect();
let bytes = to_bytes(&samples);
let report = validate(&bytes, SAMPLE_RATE).unwrap();
assert!(report.has_dc_offset, "Expected DC offset to be detected");
}
#[test]
fn validate_returns_lufs_for_speech_signal() {
let samples = speech_like(3000.0, 5.0, SAMPLE_RATE);
let bytes = to_bytes(&samples);
let report = validate(&bytes, SAMPLE_RATE).unwrap();
assert!(
report.integrated_lufs < 0.0 && report.integrated_lufs > -144.0,
"Unexpected LUFS: {:.1}",
report.integrated_lufs
);
}
#[test]
fn process_removes_dc_before_normalise() {
let samples: Vec<i16> = speech_like(1000.0, 10.0, SAMPLE_RATE)
.into_iter()
.map(|s| s.saturating_add(500))
.collect();
let bytes = to_bytes(&samples);
let out = process(&bytes, SAMPLE_RATE).unwrap();
let out_samples = bytes_to_samples(&out).unwrap();
assert!(
dc_offset::measure(&out_samples).abs() < dc_offset::DC_OFFSET_THRESHOLD,
"DC offset remains after processing"
);
}
}