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    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: OutputContainer) -> 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 == OutputContainer::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 == OutputContainer::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 == OutputContainer::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 == OutputContainer::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        // Validate channel count and sample rate before constructing inner.
241        if let Some(ch) = builder.audio_channels
242            && ch > 8
243        {
244            log::warn!("audio channel count out of range count={ch} maximum=8");
245            return Err(EncodeError::InvalidChannelCount { count: ch });
246        }
247        if let Some(sr) = builder.audio_sample_rate
248            && !(8_000..=384_000).contains(&sr)
249        {
250            log::warn!("audio sample rate out of range rate={sr} minimum=8000 maximum=384000");
251            return Err(EncodeError::InvalidSampleRate { rate: sr });
252        }
253
254        let config = AudioEncoderConfig {
255            path: builder.path.clone(),
256            sample_rate: builder
257                .audio_sample_rate
258                .ok_or_else(|| EncodeError::InvalidConfig {
259                    reason: "Audio sample rate not configured".to_string(),
260                })?,
261            channels: builder
262                .audio_channels
263                .ok_or_else(|| EncodeError::InvalidConfig {
264                    reason: "Audio channels not configured".to_string(),
265                })?,
266            codec: builder.audio_codec,
267            bitrate: builder.audio_bitrate,
268            codec_options: builder.codec_options,
269            _progress_callback: false,
270        };
271
272        let inner = Some(AudioEncoderInner::new(&config)?);
273
274        Ok(Self {
275            inner,
276            _config: config,
277            _start_time: Instant::now(),
278        })
279    }
280
281    /// Returns the name of the FFmpeg encoder actually used (e.g. `"aac"`, `"libopus"`).
282    #[must_use]
283    pub fn actual_codec(&self) -> &str {
284        self.inner
285            .as_ref()
286            .map_or("", |inner| inner.actual_codec.as_str())
287    }
288
289    /// Pushes an audio frame for encoding.
290    ///
291    /// # Errors
292    ///
293    /// Returns [`EncodeError`] if encoding fails or the encoder is not initialised.
294    pub fn push(&mut self, frame: &AudioFrame) -> Result<(), EncodeError> {
295        let inner = self
296            .inner
297            .as_mut()
298            .ok_or_else(|| EncodeError::InvalidConfig {
299                reason: "Audio encoder not initialized".to_string(),
300            })?;
301        inner.push_frame(frame)?;
302        Ok(())
303    }
304
305    /// Flushes remaining frames and writes the file trailer.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`EncodeError`] if finalising fails.
310    pub fn finish(mut self) -> Result<(), EncodeError> {
311        if let Some(mut inner) = self.inner.take() {
312            inner.finish()?;
313        }
314        Ok(())
315    }
316}
317
318impl Drop for AudioEncoder {
319    fn drop(&mut self) {
320        // AudioEncoderInner handles cleanup in its own Drop.
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn create_should_return_builder_without_error() {
330        let _builder: AudioEncoderBuilder = AudioEncoder::create("output.m4a");
331    }
332
333    #[test]
334    fn builder_audio_settings_should_be_stored() {
335        let builder = AudioEncoder::create("output.m4a")
336            .audio(48000, 2)
337            .audio_codec(AudioCodec::Aac)
338            .audio_bitrate(192_000);
339        assert_eq!(builder.audio_sample_rate, Some(48000));
340        assert_eq!(builder.audio_channels, Some(2));
341        assert_eq!(builder.audio_codec, AudioCodec::Aac);
342        assert_eq!(builder.audio_bitrate, Some(192_000));
343    }
344
345    #[test]
346    fn build_without_sample_rate_should_return_error() {
347        let result = AudioEncoder::create("output.m4a").build();
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn flac_extension_without_explicit_codec_should_default_to_flac() {
353        let builder = AudioEncoder::create("output.flac").audio(44100, 2);
354        let mut b = builder;
355        b.apply_container_defaults();
356        assert_eq!(b.audio_codec, AudioCodec::Flac);
357    }
358
359    #[test]
360    fn ogg_extension_without_explicit_codec_should_default_to_vorbis() {
361        let builder = AudioEncoder::create("output.ogg").audio(44100, 2);
362        let mut b = builder;
363        b.apply_container_defaults();
364        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
365    }
366
367    #[test]
368    fn flac_extension_with_explicit_codec_should_not_override() {
369        let builder = AudioEncoder::create("output.flac")
370            .audio(44100, 2)
371            .audio_codec(AudioCodec::Flac);
372        let mut b = builder;
373        b.apply_container_defaults();
374        assert_eq!(b.audio_codec, AudioCodec::Flac);
375    }
376
377    #[test]
378    fn flac_container_enum_without_explicit_codec_should_default_to_flac() {
379        let builder = AudioEncoder::create("output.audio")
380            .audio(44100, 2)
381            .container(OutputContainer::Flac);
382        let mut b = builder;
383        b.apply_container_defaults();
384        assert_eq!(b.audio_codec, AudioCodec::Flac);
385    }
386
387    #[test]
388    fn ogg_container_enum_without_explicit_codec_should_default_to_vorbis() {
389        let builder = AudioEncoder::create("output.audio")
390            .audio(44100, 2)
391            .container(OutputContainer::Ogg);
392        let mut b = builder;
393        b.apply_container_defaults();
394        assert_eq!(b.audio_codec, AudioCodec::Vorbis);
395    }
396
397    #[test]
398    fn flac_extension_with_incompatible_codec_should_return_error() {
399        let result = AudioEncoder::create("output.flac")
400            .audio(44100, 2)
401            .audio_codec(AudioCodec::Mp3)
402            .build();
403        assert!(
404            matches!(
405                result,
406                Err(EncodeError::UnsupportedContainerCodecCombination {
407                    ref container,
408                    ..
409                }) if container == "flac"
410            ),
411            "expected UnsupportedContainerCodecCombination for flac"
412        );
413    }
414
415    #[test]
416    fn ogg_extension_with_incompatible_codec_should_return_error() {
417        let result = AudioEncoder::create("output.ogg")
418            .audio(44100, 2)
419            .audio_codec(AudioCodec::Mp3)
420            .build();
421        assert!(
422            matches!(
423                result,
424                Err(EncodeError::UnsupportedContainerCodecCombination {
425                    ref container,
426                    ..
427                }) if container == "ogg"
428            ),
429            "expected UnsupportedContainerCodecCombination for ogg"
430        );
431    }
432
433    #[test]
434    fn ogg_with_opus_should_pass_validation() {
435        // Opus is a valid OGG codec — validation should not reject it.
436        // (build() will fail due to missing sample-rate check, but not with
437        // UnsupportedContainerCodecCombination.)
438        let result = AudioEncoder::create("output.ogg")
439            .audio_codec(AudioCodec::Opus)
440            .build();
441        assert!(!matches!(
442            result,
443            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
444        ));
445    }
446
447    #[test]
448    fn non_flac_ogg_extension_should_not_enforce_container_codecs() {
449        // A plain .mp3 path should not trigger FLAC/OGG enforcement.
450        let result = AudioEncoder::create("output.mp3")
451            .audio_codec(AudioCodec::Flac)
452            .build();
453        assert!(!matches!(
454            result,
455            Err(EncodeError::UnsupportedContainerCodecCombination { .. })
456        ));
457    }
458}