Skip to main content

ff_encode/audio/
builder.rs

1//! Audio encoder builder and public API.
2//!
3//! This module provides [`AudioEncoderBuilder`] for fluent configuration and
4//! [`AudioEncoder`] for encoding audio frames to a file.
5
6use std::path::PathBuf;
7use std::time::Instant;
8
9use ff_format::AudioFrame;
10
11use super::codec_options::{AudioCodecOptions, Mp3Quality};
12use super::encoder_inner::{AudioEncoderConfig, AudioEncoderInner};
13use crate::{AudioCodec, EncodeError, OutputContainer};
14
15/// Builder for constructing an [`AudioEncoder`].
16///
17/// Created by calling [`AudioEncoder::create()`]. Call [`build()`](Self::build)
18/// to open the output file and prepare for encoding.
19///
20/// # Examples
21///
22/// ```ignore
23/// use ff_encode::{AudioEncoder, AudioCodec};
24///
25/// let mut encoder = AudioEncoder::create("output.m4a")
26///     .audio(48000, 2)
27///     .audio_codec(AudioCodec::Aac)
28///     .build()?;
29/// ```
30pub struct AudioEncoderBuilder {
31    pub(crate) path: PathBuf,
32    pub(crate) container: Option<OutputContainer>,
33    pub(crate) audio_sample_rate: Option<u32>,
34    pub(crate) audio_channels: Option<u32>,
35    pub(crate) audio_codec: AudioCodec,
36    pub(crate) audio_bitrate: Option<u64>,
37    pub(crate) codec_options: Option<AudioCodecOptions>,
38    /// Codec-private options set by name, applied after `codec_options`.
39    pub(crate) codec_opts: Vec<(String, String)>,
40    pub(crate) audio_codec_explicit: bool,
41}
42
43impl AudioEncoderBuilder {
44    pub(crate) fn new(path: PathBuf) -> Self {
45        Self {
46            path,
47            container: None,
48            audio_sample_rate: None,
49            audio_channels: None,
50            audio_codec: AudioCodec::default(),
51            audio_bitrate: None,
52            codec_options: None,
53            codec_opts: Vec::new(),
54            audio_codec_explicit: false,
55        }
56    }
57
58    /// Configure audio stream settings.
59    #[must_use]
60    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
61        self.audio_sample_rate = Some(sample_rate);
62        self.audio_channels = Some(channels);
63        self
64    }
65
66    /// Set audio codec.
67    #[must_use]
68    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
69        self.audio_codec = codec;
70        self.audio_codec_explicit = true;
71        self
72    }
73
74    /// Set audio bitrate in bits per second.
75    #[must_use]
76    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
77        self.audio_bitrate = Some(bitrate);
78        self
79    }
80
81    /// Set container format explicitly (usually auto-detected from file extension).
82    #[must_use]
83    pub fn container(mut self, container: OutputContainer) -> Self {
84        self.container = Some(container);
85        self
86    }
87
88    /// Set a codec-*private* option by name, for the long tail that has no typed
89    /// builder (libopus and AAC tuning knobs, and so on).
90    ///
91    /// Repeatable, and applied in call order via `av_opt_set` on the codec's
92    /// `priv_data` before `avcodec_open2`, **after**
93    /// [`codec_options()`](Self::codec_options) — so a key named here overrides
94    /// the same key set through the typed API.
95    ///
96    /// # Escape-hatch semantics
97    ///
98    /// Prefer [`codec_options()`](Self::codec_options): it is validated at
99    /// compile time and portable across encoders. Nothing here is checked until
100    /// `FFmpeg` sees it, and keys are codec-specific.
101    ///
102    /// Unlike the typed options, which log and continue when an encoder does not
103    /// support them, an option rejected here fails [`build()`](Self::build) with
104    /// [`crate::EncodeError::InvalidConfig`] — the key was named explicitly, so
105    /// dropping it silently would defeat the purpose.
106    #[must_use]
107    pub fn codec_opt(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
108        self.codec_opts.push((key.into(), value.into()));
109        self
110    }
111
112    /// Set per-codec encoding options.
113    ///
114    /// The variant must match the codec set via [`audio_codec()`](Self::audio_codec).
115    /// A mismatch is silently ignored.
116    #[must_use]
117    pub fn codec_options(mut self, opts: AudioCodecOptions) -> Self {
118        self.codec_options = Some(opts);
119        self
120    }
121
122    fn apply_container_defaults(&mut self) {
123        let is_flac = self
124            .path
125            .extension()
126            .and_then(|e| e.to_str())
127            .is_some_and(|e| e.eq_ignore_ascii_case("flac"))
128            || self
129                .container
130                .as_ref()
131                .is_some_and(|c| *c == OutputContainer::Flac);
132        if is_flac && !self.audio_codec_explicit {
133            self.audio_codec = AudioCodec::Flac;
134        }
135
136        let is_ogg = self
137            .path
138            .extension()
139            .and_then(|e| e.to_str())
140            .is_some_and(|e| e.eq_ignore_ascii_case("ogg"))
141            || self
142                .container
143                .as_ref()
144                .is_some_and(|c| *c == OutputContainer::Ogg);
145        if is_ogg && !self.audio_codec_explicit {
146            self.audio_codec = AudioCodec::Vorbis;
147        }
148    }
149
150    /// Validate builder state and open the output file.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`EncodeError`] if configuration is invalid, the output path
155    /// cannot be created, or no suitable encoder is found.
156    pub fn build(self) -> Result<AudioEncoder, EncodeError> {
157        AudioEncoder::from_builder(self)
158    }
159}
160
161/// Encodes audio frames to a file using `FFmpeg`.
162///
163/// # Construction
164///
165/// Use [`AudioEncoder::create()`] to get an [`AudioEncoderBuilder`], then call
166/// [`AudioEncoderBuilder::build()`]:
167///
168/// ```ignore
169/// use ff_encode::{AudioEncoder, AudioCodec};
170///
171/// let mut encoder = AudioEncoder::create("output.m4a")
172///     .audio(48000, 2)
173///     .audio_codec(AudioCodec::Aac)
174///     .build()?;
175/// ```
176pub struct AudioEncoder {
177    inner: Option<AudioEncoderInner>,
178    _config: AudioEncoderConfig,
179    _start_time: Instant,
180}
181
182impl AudioEncoder {
183    /// Creates a builder for the specified output file path.
184    ///
185    /// This method is infallible. Validation occurs when
186    /// [`AudioEncoderBuilder::build()`] is called.
187    pub fn create<P: AsRef<std::path::Path>>(path: P) -> AudioEncoderBuilder {
188        AudioEncoderBuilder::new(path.as_ref().to_path_buf())
189    }
190
191    pub(crate) fn from_builder(mut builder: AudioEncoderBuilder) -> Result<Self, EncodeError> {
192        builder.apply_container_defaults();
193
194        // Enforce FLAC container codec allowlist.
195        let is_flac = builder
196            .path
197            .extension()
198            .and_then(|e| e.to_str())
199            .is_some_and(|e| e.eq_ignore_ascii_case("flac"))
200            || builder
201                .container
202                .as_ref()
203                .is_some_and(|c| *c == OutputContainer::Flac);
204        if is_flac && !matches!(builder.audio_codec, AudioCodec::Flac) {
205            return Err(EncodeError::UnsupportedContainerCodecCombination {
206                container: "flac".to_string(),
207                codec: builder.audio_codec.name().to_string(),
208                hint: "FLAC container only supports the FLAC codec".to_string(),
209            });
210        }
211
212        // Enforce OGG container codec allowlist.
213        let is_ogg = builder
214            .path
215            .extension()
216            .and_then(|e| e.to_str())
217            .is_some_and(|e| e.eq_ignore_ascii_case("ogg"))
218            || builder
219                .container
220                .as_ref()
221                .is_some_and(|c| *c == OutputContainer::Ogg);
222        if is_ogg && !matches!(builder.audio_codec, AudioCodec::Vorbis | AudioCodec::Opus) {
223            return Err(EncodeError::UnsupportedContainerCodecCombination {
224                container: "ogg".to_string(),
225                codec: builder.audio_codec.name().to_string(),
226                hint: "OGG container supports Vorbis and Opus".to_string(),
227            });
228        }
229
230        // Validate per-codec options before constructing the inner encoder.
231        if let Some(AudioCodecOptions::Opus(ref opts)) = builder.codec_options
232            && let Some(dur) = opts.frame_duration_ms
233            && ![2u32, 5, 10, 20, 40, 60].contains(&dur)
234        {
235            return Err(EncodeError::InvalidOption {
236                name: "frame_duration_ms".to_string(),
237                reason: "must be one of: 2, 5, 10, 20, 40, 60".to_string(),
238            });
239        }
240        if let Some(AudioCodecOptions::Aac(ref opts)) = builder.codec_options
241            && let Some(q) = opts.vbr_quality
242            && !(1..=5).contains(&q)
243        {
244            return Err(EncodeError::InvalidOption {
245                name: "vbr_quality".to_string(),
246                reason: "must be 1–5".to_string(),
247            });
248        }
249        if let Some(AudioCodecOptions::Mp3(ref opts)) = builder.codec_options
250            && let Mp3Quality::Vbr(q) = opts.quality
251            && q > 9
252        {
253            return Err(EncodeError::InvalidOption {
254                name: "vbr_quality".to_string(),
255                reason: "must be 0–9 (0=best)".to_string(),
256            });
257        }
258        if let Some(AudioCodecOptions::Flac(ref opts)) = builder.codec_options
259            && opts.compression_level > 12
260        {
261            return Err(EncodeError::InvalidOption {
262                name: "compression_level".to_string(),
263                reason: "must be 0–12".to_string(),
264            });
265        }
266
267        // Validate channel count and sample rate before constructing inner.
268        if let Some(ch) = builder.audio_channels
269            && ch > 8
270        {
271            log::warn!("audio channel count out of range count={ch} maximum=8");
272            return Err(EncodeError::InvalidChannelCount { count: ch });
273        }
274        if let Some(sr) = builder.audio_sample_rate
275            && !(8_000..=384_000).contains(&sr)
276        {
277            log::warn!("audio sample rate out of range rate={sr} minimum=8000 maximum=384000");
278            return Err(EncodeError::InvalidSampleRate { rate: sr });
279        }
280
281        let config = AudioEncoderConfig {
282            path: builder.path.clone(),
283            sample_rate: builder
284                .audio_sample_rate
285                .ok_or_else(|| EncodeError::InvalidConfig {
286                    reason: "Audio sample rate not configured".to_string(),
287                })?,
288            channels: builder
289                .audio_channels
290                .ok_or_else(|| EncodeError::InvalidConfig {
291                    reason: "Audio channels not configured".to_string(),
292                })?,
293            codec: builder.audio_codec,
294            bitrate: builder.audio_bitrate,
295            codec_options: builder.codec_options,
296            codec_opts: builder.codec_opts,
297            _progress_callback: false,
298        };
299
300        let inner = Some(AudioEncoderInner::new(&config)?);
301
302        Ok(Self {
303            inner,
304            _config: config,
305            _start_time: Instant::now(),
306        })
307    }
308
309    /// Returns the name of the `FFmpeg` encoder actually used (e.g. `"aac"`, `"libopus"`).
310    #[must_use]
311    pub fn actual_codec(&self) -> &str {
312        self.inner
313            .as_ref()
314            .map_or("", |inner| inner.actual_codec.as_str())
315    }
316
317    /// Pushes an audio frame for encoding.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
322    pub fn push(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
323        let inner = self
324            .inner
325            .as_mut()
326            .ok_or_else(|| EncodeError::InvalidConfig {
327                reason: "Audio encoder not initialized".to_string(),
328            })?;
329        inner.push_frame(frame)?;
330        Ok(())
331    }
332
333    /// Flushes remaining frames and writes the file trailer.
334    ///
335    /// # Errors
336    ///
337    /// Returns [`EncodeError`] if finalising fails.
338    pub fn finish(mut self) -> Result<(), EncodeError> {
339        if let Some(mut inner) = self.inner.take() {
340            inner.finish()?;
341        }
342        Ok(())
343    }
344}
345
346impl Drop for AudioEncoder {
347    fn drop(&mut self) {
348        // AudioEncoderInner handles cleanup in its own Drop.
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    #[test]
355    fn audio_codec_opt_should_collect_pairs_in_order() {
356        let builder = crate::AudioEncoder::create("out.m4a")
357            .codec_opt("frame_duration", "20")
358            .codec_opt("vbr", "on");
359        assert_eq!(
360            builder.codec_opts,
361            vec![
362                ("frame_duration".to_string(), "20".to_string()),
363                ("vbr".to_string(), "on".to_string()),
364            ],
365            "pairs must be kept in call order"
366        );
367    }
368
369    use super::*;
370
371    #[test]
372    fn create_should_return_builder_without_error() {
373        let _builder: AudioEncoderBuilder = AudioEncoder::create("output.m4a");
374    }
375
376    #[test]
377    fn builder_audio_settings_should_be_stored() {
378        let builder = AudioEncoder::create("output.m4a")
379            .audio(48000, 2)
380            .audio_codec(AudioCodec::Aac)
381            .audio_bitrate(192_000);
382        assert_eq!(builder.audio_sample_rate, Some(48000));
383        assert_eq!(builder.audio_channels, Some(2));
384        assert_eq!(builder.audio_codec, AudioCodec::Aac);
385        assert_eq!(builder.audio_bitrate, Some(192_000));
386    }
387
388    #[test]
389    fn build_without_sample_rate_should_return_error() {
390        let result = AudioEncoder::create("output.m4a").build();
391        assert!(result.is_err());
392    }
393
394    #[test]
395    fn flac_extension_without_explicit_codec_should_default_to_flac() {
396        let builder = AudioEncoder::create("output.flac").audio(44100, 2);
397        let mut b = builder;
398        b.apply_container_defaults();
399        assert_eq!(b.audio_codec, AudioCodec::Flac);
400    }
401
402    #[test]
403    fn ogg_extension_without_explicit_codec_should_default_to_vorbis() {
404        let builder = AudioEncoder::create("output.ogg").audio(44100, 2);
405        let mut b = builder;
406        b.apply_container_defaults();
407        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
408    }
409
410    #[test]
411    fn flac_extension_with_explicit_codec_should_not_override() {
412        let builder = AudioEncoder::create("output.flac")
413            .audio(44100, 2)
414            .audio_codec(AudioCodec::Flac);
415        let mut b = builder;
416        b.apply_container_defaults();
417        assert_eq!(b.audio_codec, AudioCodec::Flac);
418    }
419
420    #[test]
421    fn flac_container_enum_without_explicit_codec_should_default_to_flac() {
422        let builder = AudioEncoder::create("output.audio")
423            .audio(44100, 2)
424            .container(OutputContainer::Flac);
425        let mut b = builder;
426        b.apply_container_defaults();
427        assert_eq!(b.audio_codec, AudioCodec::Flac);
428    }
429
430    #[test]
431    fn ogg_container_enum_without_explicit_codec_should_default_to_vorbis() {
432        let builder = AudioEncoder::create("output.audio")
433            .audio(44100, 2)
434            .container(OutputContainer::Ogg);
435        let mut b = builder;
436        b.apply_container_defaults();
437        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
438    }
439
440    #[test]
441    fn flac_extension_with_incompatible_codec_should_return_error() {
442        let result = AudioEncoder::create("output.flac")
443            .audio(44100, 2)
444            .audio_codec(AudioCodec::Mp3)
445            .build();
446        assert!(
447            matches!(
448                result,
449                Err(EncodeError::UnsupportedContainerCodecCombination {
450                    ref container,
451                    ..
452                }) if container == "flac"
453            ),
454            "expected UnsupportedContainerCodecCombination for flac"
455        );
456    }
457
458    #[test]
459    fn ogg_extension_with_incompatible_codec_should_return_error() {
460        let result = AudioEncoder::create("output.ogg")
461            .audio(44100, 2)
462            .audio_codec(AudioCodec::Mp3)
463            .build();
464        assert!(
465            matches!(
466                result,
467                Err(EncodeError::UnsupportedContainerCodecCombination {
468                    ref container,
469                    ..
470                }) if container == "ogg"
471            ),
472            "expected UnsupportedContainerCodecCombination for ogg"
473        );
474    }
475
476    #[test]
477    fn ogg_with_opus_should_pass_validation() {
478        // Opus is a valid OGG codec — validation should not reject it.
479        // (build() will fail due to missing sample-rate check, but not with
480        // UnsupportedContainerCodecCombination.)
481        let result = AudioEncoder::create("output.ogg")
482            .audio_codec(AudioCodec::Opus)
483            .build();
484        assert!(!matches!(
485            result,
486            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
487        ));
488    }
489
490    #[test]
491    fn non_flac_ogg_extension_should_not_enforce_container_codecs() {
492        // A plain .mp3 path should not trigger FLAC/OGG enforcement.
493        let result = AudioEncoder::create("output.mp3")
494            .audio_codec(AudioCodec::Flac)
495            .build();
496        assert!(!matches!(
497            result,
498            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
499        ));
500    }
501}