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, Container, EncodeError};
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<Container>,
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    pub(crate) audio_codec_explicit: bool,
39}
40
41impl AudioEncoderBuilder {
42    pub(crate) fn new(path: PathBuf) -> Self {
43        Self {
44            path,
45            container: None,
46            audio_sample_rate: None,
47            audio_channels: None,
48            audio_codec: AudioCodec::default(),
49            audio_bitrate: None,
50            codec_options: None,
51            audio_codec_explicit: false,
52        }
53    }
54
55    /// Configure audio stream settings.
56    #[must_use]
57    pub fn audio(mut self, sample_rate: u32, channels: u32) -> Self {
58        self.audio_sample_rate = Some(sample_rate);
59        self.audio_channels = Some(channels);
60        self
61    }
62
63    /// Set audio codec.
64    #[must_use]
65    pub fn audio_codec(mut self, codec: AudioCodec) -> Self {
66        self.audio_codec = codec;
67        self.audio_codec_explicit = true;
68        self
69    }
70
71    /// Set audio bitrate in bits per second.
72    #[must_use]
73    pub fn audio_bitrate(mut self, bitrate: u64) -> Self {
74        self.audio_bitrate = Some(bitrate);
75        self
76    }
77
78    /// Set container format explicitly (usually auto-detected from file extension).
79    #[must_use]
80    pub fn container(mut self, container: Container) -> Self {
81        self.container = Some(container);
82        self
83    }
84
85    /// Set per-codec encoding options.
86    ///
87    /// The variant must match the codec set via [`audio_codec()`](Self::audio_codec).
88    /// A mismatch is silently ignored.
89    #[must_use]
90    pub fn codec_options(mut self, opts: AudioCodecOptions) -> Self {
91        self.codec_options = Some(opts);
92        self
93    }
94
95    fn apply_container_defaults(&mut self) {
96        let is_flac = self
97            .path
98            .extension()
99            .and_then(|e| e.to_str())
100            .is_some_and(|e| e.eq_ignore_ascii_case("flac"))
101            || self
102                .container
103                .as_ref()
104                .is_some_and(|c| *c == Container::Flac);
105        if is_flac && !self.audio_codec_explicit {
106            self.audio_codec = AudioCodec::Flac;
107        }
108
109        let is_ogg = self
110            .path
111            .extension()
112            .and_then(|e| e.to_str())
113            .is_some_and(|e| e.eq_ignore_ascii_case("ogg"))
114            || self
115                .container
116                .as_ref()
117                .is_some_and(|c| *c == Container::Ogg);
118        if is_ogg && !self.audio_codec_explicit {
119            self.audio_codec = AudioCodec::Vorbis;
120        }
121    }
122
123    /// Validate builder state and open the output file.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`EncodeError`] if configuration is invalid, the output path
128    /// cannot be created, or no suitable encoder is found.
129    pub fn build(self) -> Result<AudioEncoder, EncodeError> {
130        AudioEncoder::from_builder(self)
131    }
132}
133
134/// Encodes audio frames to a file using FFmpeg.
135///
136/// # Construction
137///
138/// Use [`AudioEncoder::create()`] to get an [`AudioEncoderBuilder`], then call
139/// [`AudioEncoderBuilder::build()`]:
140///
141/// ```ignore
142/// use ff_encode::{AudioEncoder, AudioCodec};
143///
144/// let mut encoder = AudioEncoder::create("output.m4a")
145///     .audio(48000, 2)
146///     .audio_codec(AudioCodec::Aac)
147///     .build()?;
148/// ```
149pub struct AudioEncoder {
150    inner: Option<AudioEncoderInner>,
151    _config: AudioEncoderConfig,
152    _start_time: Instant,
153}
154
155impl AudioEncoder {
156    /// Creates a builder for the specified output file path.
157    ///
158    /// This method is infallible. Validation occurs when
159    /// [`AudioEncoderBuilder::build()`] is called.
160    pub fn create<P: AsRef<std::path::Path>>(path: P) -> AudioEncoderBuilder {
161        AudioEncoderBuilder::new(path.as_ref().to_path_buf())
162    }
163
164    pub(crate) fn from_builder(mut builder: AudioEncoderBuilder) -> Result<Self, EncodeError> {
165        builder.apply_container_defaults();
166
167        // Enforce FLAC container codec allowlist.
168        let is_flac = builder
169            .path
170            .extension()
171            .and_then(|e| e.to_str())
172            .is_some_and(|e| e.eq_ignore_ascii_case("flac"))
173            || builder
174                .container
175                .as_ref()
176                .is_some_and(|c| *c == Container::Flac);
177        if is_flac && !matches!(builder.audio_codec, AudioCodec::Flac) {
178            return Err(EncodeError::UnsupportedContainerCodecCombination {
179                container: "flac".to_string(),
180                codec: builder.audio_codec.name().to_string(),
181                hint: "FLAC container only supports the FLAC codec".to_string(),
182            });
183        }
184
185        // Enforce OGG container codec allowlist.
186        let is_ogg = builder
187            .path
188            .extension()
189            .and_then(|e| e.to_str())
190            .is_some_and(|e| e.eq_ignore_ascii_case("ogg"))
191            || builder
192                .container
193                .as_ref()
194                .is_some_and(|c| *c == Container::Ogg);
195        if is_ogg && !matches!(builder.audio_codec, AudioCodec::Vorbis | AudioCodec::Opus) {
196            return Err(EncodeError::UnsupportedContainerCodecCombination {
197                container: "ogg".to_string(),
198                codec: builder.audio_codec.name().to_string(),
199                hint: "OGG container supports Vorbis and Opus".to_string(),
200            });
201        }
202
203        // Validate per-codec options before constructing the inner encoder.
204        if let Some(AudioCodecOptions::Opus(ref opts)) = builder.codec_options
205            && let Some(dur) = opts.frame_duration_ms
206            && ![2u32, 5, 10, 20, 40, 60].contains(&dur)
207        {
208            return Err(EncodeError::InvalidOption {
209                name: "frame_duration_ms".to_string(),
210                reason: "must be one of: 2, 5, 10, 20, 40, 60".to_string(),
211            });
212        }
213        if let Some(AudioCodecOptions::Aac(ref opts)) = builder.codec_options
214            && let Some(q) = opts.vbr_quality
215            && !(1..=5).contains(&q)
216        {
217            return Err(EncodeError::InvalidOption {
218                name: "vbr_quality".to_string(),
219                reason: "must be 1–5".to_string(),
220            });
221        }
222        if let Some(AudioCodecOptions::Mp3(ref opts)) = builder.codec_options
223            && let Mp3Quality::Vbr(q) = opts.quality
224            && q > 9
225        {
226            return Err(EncodeError::InvalidOption {
227                name: "vbr_quality".to_string(),
228                reason: "must be 0–9 (0=best)".to_string(),
229            });
230        }
231        if let Some(AudioCodecOptions::Flac(ref opts)) = builder.codec_options
232            && opts.compression_level > 12
233        {
234            return Err(EncodeError::InvalidOption {
235                name: "compression_level".to_string(),
236                reason: "must be 0–12".to_string(),
237            });
238        }
239
240        let config = AudioEncoderConfig {
241            path: builder.path.clone(),
242            sample_rate: builder
243                .audio_sample_rate
244                .ok_or_else(|| EncodeError::InvalidConfig {
245                    reason: "Audio sample rate not configured".to_string(),
246                })?,
247            channels: builder
248                .audio_channels
249                .ok_or_else(|| EncodeError::InvalidConfig {
250                    reason: "Audio channels not configured".to_string(),
251                })?,
252            codec: builder.audio_codec,
253            bitrate: builder.audio_bitrate,
254            codec_options: builder.codec_options,
255            _progress_callback: false,
256        };
257
258        let inner = Some(AudioEncoderInner::new(&config)?);
259
260        Ok(Self {
261            inner,
262            _config: config,
263            _start_time: Instant::now(),
264        })
265    }
266
267    /// Returns the name of the FFmpeg encoder actually used (e.g. `"aac"`, `"libopus"`).
268    #[must_use]
269    pub fn actual_codec(&self) -> &str {
270        self.inner
271            .as_ref()
272            .map_or("", |inner| inner.actual_codec.as_str())
273    }
274
275    /// Pushes an audio frame for encoding.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
280    pub fn push(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
281        let inner = self
282            .inner
283            .as_mut()
284            .ok_or_else(|| EncodeError::InvalidConfig {
285                reason: "Audio encoder not initialized".to_string(),
286            })?;
287        // SAFETY: inner is properly initialised and we have exclusive access.
288        unsafe { inner.push_frame(frame)? };
289        Ok(())
290    }
291
292    /// Flushes remaining frames and writes the file trailer.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`EncodeError`] if finalising fails.
297    pub fn finish(mut self) -> Result<(), EncodeError> {
298        if let Some(mut inner) = self.inner.take() {
299            // SAFETY: inner is properly initialised and we have exclusive access.
300            unsafe { inner.finish()? };
301        }
302        Ok(())
303    }
304}
305
306impl Drop for AudioEncoder {
307    fn drop(&mut self) {
308        // AudioEncoderInner handles cleanup in its own Drop.
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn create_should_return_builder_without_error() {
318        let _builder: AudioEncoderBuilder = AudioEncoder::create("output.m4a");
319    }
320
321    #[test]
322    fn builder_audio_settings_should_be_stored() {
323        let builder = AudioEncoder::create("output.m4a")
324            .audio(48000, 2)
325            .audio_codec(AudioCodec::Aac)
326            .audio_bitrate(192_000);
327        assert_eq!(builder.audio_sample_rate, Some(48000));
328        assert_eq!(builder.audio_channels, Some(2));
329        assert_eq!(builder.audio_codec, AudioCodec::Aac);
330        assert_eq!(builder.audio_bitrate, Some(192_000));
331    }
332
333    #[test]
334    fn build_without_sample_rate_should_return_error() {
335        let result = AudioEncoder::create("output.m4a").build();
336        assert!(result.is_err());
337    }
338
339    #[test]
340    fn flac_extension_without_explicit_codec_should_default_to_flac() {
341        let builder = AudioEncoder::create("output.flac").audio(44100, 2);
342        let mut b = builder;
343        b.apply_container_defaults();
344        assert_eq!(b.audio_codec, AudioCodec::Flac);
345    }
346
347    #[test]
348    fn ogg_extension_without_explicit_codec_should_default_to_vorbis() {
349        let builder = AudioEncoder::create("output.ogg").audio(44100, 2);
350        let mut b = builder;
351        b.apply_container_defaults();
352        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
353    }
354
355    #[test]
356    fn flac_extension_with_explicit_codec_should_not_override() {
357        let builder = AudioEncoder::create("output.flac")
358            .audio(44100, 2)
359            .audio_codec(AudioCodec::Flac);
360        let mut b = builder;
361        b.apply_container_defaults();
362        assert_eq!(b.audio_codec, AudioCodec::Flac);
363    }
364
365    #[test]
366    fn flac_container_enum_without_explicit_codec_should_default_to_flac() {
367        let builder = AudioEncoder::create("output.audio")
368            .audio(44100, 2)
369            .container(Container::Flac);
370        let mut b = builder;
371        b.apply_container_defaults();
372        assert_eq!(b.audio_codec, AudioCodec::Flac);
373    }
374
375    #[test]
376    fn ogg_container_enum_without_explicit_codec_should_default_to_vorbis() {
377        let builder = AudioEncoder::create("output.audio")
378            .audio(44100, 2)
379            .container(Container::Ogg);
380        let mut b = builder;
381        b.apply_container_defaults();
382        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
383    }
384
385    #[test]
386    fn flac_extension_with_incompatible_codec_should_return_error() {
387        let result = AudioEncoder::create("output.flac")
388            .audio(44100, 2)
389            .audio_codec(AudioCodec::Mp3)
390            .build();
391        assert!(
392            matches!(
393                result,
394                Err(EncodeError::UnsupportedContainerCodecCombination {
395                    ref container,
396                    ..
397                }) if container == "flac"
398            ),
399            "expected UnsupportedContainerCodecCombination for flac"
400        );
401    }
402
403    #[test]
404    fn ogg_extension_with_incompatible_codec_should_return_error() {
405        let result = AudioEncoder::create("output.ogg")
406            .audio(44100, 2)
407            .audio_codec(AudioCodec::Mp3)
408            .build();
409        assert!(
410            matches!(
411                result,
412                Err(EncodeError::UnsupportedContainerCodecCombination {
413                    ref container,
414                    ..
415                }) if container == "ogg"
416            ),
417            "expected UnsupportedContainerCodecCombination for ogg"
418        );
419    }
420
421    #[test]
422    fn ogg_with_opus_should_pass_validation() {
423        // Opus is a valid OGG codec — validation should not reject it.
424        // (build() will fail due to missing sample-rate check, but not with
425        // UnsupportedContainerCodecCombination.)
426        let result = AudioEncoder::create("output.ogg")
427            .audio_codec(AudioCodec::Opus)
428            .build();
429        assert!(!matches!(
430            result,
431            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
432        ));
433    }
434
435    #[test]
436    fn non_flac_ogg_extension_should_not_enforce_container_codecs() {
437        // A plain .mp3 path should not trigger FLAC/OGG enforcement.
438        let result = AudioEncoder::create("output.mp3")
439            .audio_codec(AudioCodec::Flac)
440            .build();
441        assert!(!matches!(
442            result,
443            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
444        ));
445    }
446}