use std::io::Write;
use std::path::Path;
use shine_rs::{Mp3Encoder, Mp3EncoderConfig, StereoMode, SUPPORTED_BITRATES};
use crate::atomic_output::{AtomicOutput, CommitMode};
use crate::audio::Audio;
use super::pcm::{lossy_channel_layout, planar_f64_to_interleaved_i16, EncodeChannels};
use super::{DownmixMode, EncodeOptions, OutputFormat};
pub const DEFAULT_MP3_BITRATE: u32 = 192;
pub fn write_mp3<P: AsRef<Path>>(path: P, audio: &Audio, bitrate_kbps: u32) -> Result<(), String> {
write_mp3_with_downmix(path, audio, bitrate_kbps, DownmixMode::Preserve)
}
pub fn write_mp3_with_downmix<P: AsRef<Path>>(
path: P,
audio: &Audio,
bitrate_kbps: u32,
downmix: DownmixMode,
) -> Result<(), String> {
EncodeOptions {
mp3_bitrate_kbps: bitrate_kbps,
downmix,
..EncodeOptions::default()
}
.validate_config(OutputFormat::Mp3, audio)?;
let mut output = AtomicOutput::new(path)?;
write_mp3_to_writer(output.file_mut(), audio, bitrate_kbps, downmix)?;
output.commit(CommitMode::Replace)
}
pub(super) fn write_mp3_to_writer<W: Write>(
mut output: W,
audio: &Audio,
bitrate_kbps: u32,
downmix: DownmixMode,
) -> Result<(), String> {
let (layout, config) = effective_mp3_config(audio, bitrate_kbps, downmix)?;
let mut pcm = planar_f64_to_interleaved_i16(audio, layout)?;
let mut encoder = Mp3Encoder::new(config).map_err(|e| format!("mp3 encoder: {e}"))?;
let minimum_samples = encoder
.samples_per_frame()
.checked_mul(2)
.ok_or_else(|| "MP3 minimum frame count overflows".to_string())?;
if pcm.len() < minimum_samples {
pcm.resize(minimum_samples, 0);
}
let mut mp3 = Vec::new();
for frame in encoder
.encode_interleaved(&pcm)
.map_err(|e| format!("mp3 encode: {e}"))?
{
mp3.extend(frame);
}
mp3.extend(encoder.finish().map_err(|e| format!("mp3 finish: {e}"))?);
encoder
.shine_config()
.bs
.flush()
.map_err(|e| format!("mp3 bitstream flush: {e}"))?;
let config = encoder.shine_config();
let (flush_data, flush_written) = shine_rs::shine_flush(config);
mp3.extend_from_slice(&flush_data[..flush_written]);
output
.write_all(&mp3)
.map_err(|e| format!("write mp3: {e}"))?;
output.flush().map_err(|e| format!("flush mp3: {e}"))
}
pub(super) fn effective_mp3_config(
audio: &Audio,
requested_bitrate_kbps: u32,
downmix: DownmixMode,
) -> Result<(EncodeChannels, Mp3EncoderConfig), String> {
if audio.frames() == 0 {
return Err("MP3 output requires at least one frame".into());
}
if !shine_rs::SUPPORTED_SAMPLE_RATES.contains(&audio.sample_rate) {
return Err(format!(
"MP3 encode: unsupported sample rate {} Hz (supported: {:?})",
audio.sample_rate,
shine_rs::SUPPORTED_SAMPLE_RATES,
));
}
let layout = lossy_channel_layout(audio, downmix)?;
let stereo_mode = if layout.is_stereo {
StereoMode::JointStereo
} else {
StereoMode::Mono
};
let build_config = |bitrate| Mp3EncoderConfig {
sample_rate: audio.sample_rate,
bitrate,
channels: layout.count,
stereo_mode,
copyright: false,
original: true,
};
let bitrate = SUPPORTED_BITRATES
.iter()
.copied()
.filter(|bitrate| *bitrate <= requested_bitrate_kbps)
.rev()
.find(|bitrate| build_config(*bitrate).validate().is_ok())
.or_else(|| {
SUPPORTED_BITRATES
.iter()
.copied()
.find(|bitrate| build_config(*bitrate).validate().is_ok())
})
.ok_or_else(|| {
format!(
"MP3 encode: no compatible bitrate for {} Hz and {} channel(s)",
audio.sample_rate, layout.count
)
})?;
let config = build_config(bitrate);
config
.validate()
.map_err(|error| format!("MP3 encoder config: {error}"))?;
Ok((layout, config))
}
#[cfg(test)]
mod tests {
use super::*;
use hound::SampleFormat;
fn sine_stereo(sr: u32, secs: f32) -> Audio {
let frames = (sr as f32 * secs) as usize;
let mut l = Vec::with_capacity(frames);
let mut r = Vec::with_capacity(frames);
for i in 0..frames {
let t = i as f64 / sr as f64;
let v = (2.0 * std::f64::consts::PI * 440.0 * t).sin() * 0.25;
l.push(v);
r.push(v * 0.8);
}
Audio {
sample_rate: sr,
channels: vec![l, r],
bits_per_sample: 32,
sample_format: SampleFormat::Float,
channel_mask: None,
}
}
fn tmp(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("denoize_mp3_{}_{}", std::process::id(), name));
p
}
#[test]
fn mp3_roundtrip_decode() {
let path = tmp("rt.mp3");
let audio = sine_stereo(44100, 0.5);
write_mp3(&path, &audio, 128).unwrap();
assert!(path.metadata().unwrap().len() > 100);
let decoded = crate::decode::decode_file(&path).unwrap();
assert_eq!(decoded.sample_rate, 44100);
assert_eq!(decoded.n_channels(), 2);
assert_eq!(
decoded.channel_mask,
crate::channel_layout::ChannelLayout::Stereo.mask()
);
assert!(decoded.frames() > 10000);
let rms_in: f64 =
audio.channels[0].iter().map(|s| s * s).sum::<f64>() / audio.frames() as f64;
let rms_out: f64 =
decoded.channels[0].iter().map(|s| s * s).sum::<f64>() / decoded.frames() as f64;
assert!(rms_out > 0.01);
assert!(rms_out < rms_in * 2.0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn direct_writer_validates_before_staging() {
let path = tmp("preserve.mp3");
std::fs::write(&path, b"existing output").unwrap();
let audio = sine_stereo(12_345, 0.1);
let error = write_mp3(&path, &audio, 128).unwrap_err();
assert!(error.contains("unsupported sample rate"));
assert_eq!(std::fs::read(&path).unwrap(), b"existing output");
let _ = std::fs::remove_file(path);
}
#[test]
fn subminimum_bitrate_uses_the_lowest_rate_compatible_fallback() {
for sample_rate in [32_000, 44_100, 48_000] {
let audio = sine_stereo(sample_rate, 0.01);
for requested in [0, 8, 16, 24, 31] {
let (_, config) =
effective_mp3_config(&audio, requested, DownmixMode::Preserve).unwrap();
assert_eq!(config.bitrate, 32);
config.validate().unwrap();
}
}
}
#[test]
fn compatible_bitrates_still_round_down() {
let audio = sine_stereo(44_100, 0.01);
for (requested, expected) in [(33, 32), (191, 160), (u32::MAX, 320)] {
let (_, config) =
effective_mp3_config(&audio, requested, DownmixMode::Preserve).unwrap();
assert_eq!(config.bitrate, expected);
config.validate().unwrap();
}
}
}