Skip to main content

denoize/encode/
mod.rs

1//! Audio encode layer — WAV / MP3 / M4A output.
2//!
3//! | Format | Backend |
4//! |--------|---------|
5//! | WAV | `hound` (lossless, preserves bit depth) |
6//! | MP3 | `shine-rs` (Pure Rust) |
7//! | M4A | `oxideav-aac` + `mp4` mux (Pure-Rust AAC-LC) |
8
9#[cfg(feature = "m4a-encode")]
10mod aac;
11#[cfg(feature = "m4a-encode")]
12mod m4a;
13#[cfg(feature = "fdk-aac-encoder")]
14mod m4a_fdk;
15mod mp3;
16mod opus;
17mod pcm;
18
19#[cfg(feature = "m4a-encode")]
20pub use aac::{write_adts_aac, write_adts_aac_with_downmix};
21#[cfg(feature = "m4a-encode")]
22pub use m4a::{write_m4a, write_m4a_with_downmix};
23#[cfg(feature = "fdk-aac-encoder")]
24pub use m4a_fdk::{write_m4a_fdk, write_m4a_fdk_with_downmix};
25pub use mp3::{write_mp3, write_mp3_with_downmix, DEFAULT_MP3_BITRATE};
26
27/// Default AAC bitrate (bps, not kbps).
28pub const DEFAULT_M4A_BITRATE: u32 = 192_000;
29
30/// Policy for reducing a surround input to a codec's supported channel count.
31///
32/// `Preserve` is deliberately the default: a 5.1/7.1 input must never be
33/// silently mixed into stereo.  Select `Stereo` only when that loss of spatial
34/// channels is intentional.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub enum DownmixMode {
37    /// Refuse an output codec that cannot represent the input layout.
38    #[default]
39    Preserve,
40    /// Explicitly render a standard surround layout to stereo.
41    Stereo,
42}
43
44impl DownmixMode {
45    pub fn parse(value: &str) -> Option<Self> {
46        match value.to_ascii_lowercase().as_str() {
47            "preserve" | "none" | "off" => Some(Self::Preserve),
48            "stereo" | "2" | "on" => Some(Self::Stereo),
49            _ => None,
50        }
51    }
52}
53
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum AacEncoder {
56    #[default]
57    Oxide,
58    Fdk,
59}
60
61impl AacEncoder {
62    pub fn parse(value: &str) -> Option<Self> {
63        match value.to_ascii_lowercase().as_str() {
64            "oxide" | "oxideav" | "rust" => Some(Self::Oxide),
65            "fdk" | "fdk-aac" => Some(Self::Fdk),
66            _ => None,
67        }
68    }
69}
70
71use std::path::Path;
72
73use crate::audio::{sanitize_sample, write_wav, Audio};
74
75/// Output container inferred from file extension.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum OutputFormat {
78    Wav,
79    Flac,
80    OggOpus,
81    Mp3,
82    M4a,
83    AacAdts,
84}
85
86impl OutputFormat {
87    pub fn from_path(path: &Path) -> Result<Self, String> {
88        match path
89            .extension()
90            .and_then(|e| e.to_str())
91            .map(|s| s.to_ascii_lowercase())
92            .as_deref()
93        {
94            Some("wav") => Ok(OutputFormat::Wav),
95            Some("flac") => Ok(OutputFormat::Flac),
96            Some("opus" | "ogg") => Ok(OutputFormat::OggOpus),
97            Some("mp3") => Ok(OutputFormat::Mp3),
98            Some("m4a" | "m4b" | "mp4") => Ok(OutputFormat::M4a),
99            Some("aac") => Ok(OutputFormat::AacAdts),
100            Some(ext) => Err(format!(
101                "unsupported output format '.{ext}'; use .wav, .flac, .opus, .mp3, .m4a, or .aac"
102            )),
103            None => Err(
104                "output path has no extension; use .wav, .flac, .opus, .mp3, .m4a, or .aac".into(),
105            ),
106        }
107    }
108}
109
110/// Encoding options for lossy outputs.
111#[derive(Clone, Copy, Debug)]
112pub struct EncodeOptions {
113    /// MP3 constant bitrate in kbps.
114    pub mp3_bitrate_kbps: u32,
115    /// AAC constant bitrate in bps.
116    pub m4a_bitrate_bps: u32,
117    pub aac_encoder: AacEncoder,
118    /// Explicit policy for multichannel lossy outputs.
119    pub downmix: DownmixMode,
120}
121
122impl Default for EncodeOptions {
123    fn default() -> Self {
124        Self {
125            mp3_bitrate_kbps: DEFAULT_MP3_BITRATE,
126            m4a_bitrate_bps: DEFAULT_M4A_BITRATE,
127            aac_encoder: AacEncoder::Oxide,
128            downmix: DownmixMode::Preserve,
129        }
130    }
131}
132
133/// Write audio to a file; format is chosen from the path extension.
134pub fn write_audio<P: AsRef<Path>>(
135    path: P,
136    audio: &Audio,
137    options: EncodeOptions,
138) -> Result<(), String> {
139    let path = path.as_ref();
140    match OutputFormat::from_path(path)? {
141        OutputFormat::Wav => write_wav(path, audio),
142        OutputFormat::Flac => write_flac(path, audio),
143        OutputFormat::OggOpus => opus::write_ogg_opus(path, audio, 128_000, options.downmix),
144        OutputFormat::Mp3 => {
145            write_mp3_with_downmix(path, audio, options.mp3_bitrate_kbps, options.downmix)
146        }
147        OutputFormat::M4a => {
148            #[cfg(feature = "m4a-encode")]
149            {
150                match options.aac_encoder {
151                    AacEncoder::Oxide => write_m4a_with_downmix(
152                        path,
153                        audio,
154                        options.m4a_bitrate_bps,
155                        options.downmix,
156                    ),
157                    AacEncoder::Fdk => {
158                        #[cfg(feature = "fdk-aac-encoder")]
159                        {
160                            write_m4a_fdk_with_downmix(
161                                path,
162                                audio,
163                                options.m4a_bitrate_bps,
164                                options.downmix,
165                            )
166                        }
167                        #[cfg(not(feature = "fdk-aac-encoder"))]
168                        {
169                            Err("FDK-AAC is unavailable in this build; rebuild with --features fdk-aac-encoder".into())
170                        }
171                    }
172                }
173            }
174            #[cfg(not(feature = "m4a-encode"))]
175            {
176                let _ = options;
177                Err("M4A output is unavailable in the crates.io build; use WAV/MP3 or a GitHub release binary".into())
178            }
179        }
180        OutputFormat::AacAdts => {
181            #[cfg(feature = "m4a-encode")]
182            {
183                if options.aac_encoder == AacEncoder::Fdk {
184                    return Err(
185                        "FDK-AAC ADTS output is not available; use M4A or --aac-encoder oxide"
186                            .into(),
187                    );
188                }
189                write_adts_aac_with_downmix(path, audio, options.m4a_bitrate_bps, options.downmix)
190            }
191            #[cfg(not(feature = "m4a-encode"))]
192            {
193                Err(
194                    "AAC output is unavailable in this build; rebuild with --features m4a-encode"
195                        .into(),
196                )
197            }
198        }
199    }
200}
201
202fn write_flac(path: &Path, audio: &Audio) -> Result<(), String> {
203    if audio.channels() == 0 {
204        return Err("FLAC output requires at least one channel".into());
205    }
206    if audio.frames() == 0 {
207        return Err("FLAC output requires at least one frame".into());
208    }
209    use flacenc::component::BitRepr;
210    use flacenc::error::Verify;
211    let bits = audio.bits_per_sample.clamp(8, 24) as usize;
212    let scale = (1_i64 << (bits - 1)) as f64;
213    let mut samples = Vec::with_capacity(audio.frames() * audio.channels());
214    for frame in 0..audio.frames() {
215        for channel in &audio.channels {
216            samples.push(
217                (sanitize_sample(channel[frame]) * scale)
218                    .round()
219                    .clamp(-scale, scale - 1.0) as i32,
220            );
221        }
222    }
223    let config = flacenc::config::Encoder::default()
224        .into_verified()
225        .map_err(|e| format!("FLAC config: {:?}", e.1))?;
226    let source = flacenc::source::MemSource::from_samples(
227        &samples,
228        audio.channels(),
229        bits,
230        audio.sample_rate as usize,
231    );
232    let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
233        .map_err(|e| format!("FLAC encode: {e}"))?;
234    let mut sink = flacenc::bitsink::ByteSink::new();
235    stream
236        .write(&mut sink)
237        .map_err(|e| format!("FLAC serialize: {e}"))?;
238    std::fs::write(path, sink.as_slice()).map_err(|e| format!("FLAC write: {e}"))
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn detect_output_formats() {
247        assert_eq!(
248            OutputFormat::from_path(Path::new("out.mp3")).unwrap(),
249            OutputFormat::Mp3
250        );
251        assert_eq!(
252            OutputFormat::from_path(Path::new("out.m4a")).unwrap(),
253            OutputFormat::M4a
254        );
255        assert_eq!(
256            OutputFormat::from_path(Path::new("out.aac")).unwrap(),
257            OutputFormat::AacAdts
258        );
259        assert_eq!(
260            OutputFormat::from_path(Path::new("out.flac")).unwrap(),
261            OutputFormat::Flac
262        );
263        assert_eq!(
264            OutputFormat::from_path(Path::new("out.opus")).unwrap(),
265            OutputFormat::OggOpus
266        );
267    }
268}