Skip to main content

oximedia_transcode/
audio_only.rs

1//! Audio-only transcoding mode.
2//!
3//! Provides configuration and transcoding logic for audio-only pipelines,
4//! including codec selection, sample-rate conversion stub, and channel mapping.
5
6use crate::{Result, TranscodeError};
7
8/// Identifies a specific audio codec.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum AudioCodecId {
11    /// Opus – modern, low-latency, patent-free.
12    Opus,
13    /// FLAC – Free Lossless Audio Codec.
14    Flac,
15    /// Vorbis – open source lossy codec.
16    Vorbis,
17    /// MP3 – MPEG Layer III (patents expired 2017).
18    Mp3,
19    /// AAC – Advanced Audio Coding.
20    Aac,
21    /// PCM – uncompressed linear PCM.
22    Pcm,
23}
24
25impl AudioCodecId {
26    /// Returns the common name string for this codec.
27    #[must_use]
28    pub fn name(self) -> &'static str {
29        match self {
30            Self::Opus => "opus",
31            Self::Flac => "flac",
32            Self::Vorbis => "vorbis",
33            Self::Mp3 => "mp3",
34            Self::Aac => "aac",
35            Self::Pcm => "pcm",
36        }
37    }
38
39    /// Returns `true` if this codec is lossless.
40    #[must_use]
41    pub fn is_lossless(self) -> bool {
42        matches!(self, Self::Flac | Self::Pcm)
43    }
44
45    /// Returns the typical default bitrate (bits per second) for the codec at stereo 48 kHz.
46    #[must_use]
47    pub fn default_bitrate(self) -> u32 {
48        match self {
49            Self::Opus => 128_000,
50            Self::Flac => 0, // lossless – no fixed bitrate
51            Self::Vorbis => 128_000,
52            Self::Mp3 => 192_000,
53            Self::Aac => 192_000,
54            Self::Pcm => 0, // uncompressed – no fixed bitrate
55        }
56    }
57}
58
59/// Configuration for an audio-only transcode operation.
60#[derive(Debug, Clone)]
61pub struct AudioOnlyConfig {
62    /// Codec of the input audio stream.
63    pub input_codec: AudioCodecId,
64    /// Codec to encode the output audio to.
65    pub output_codec: AudioCodecId,
66    /// Target sample rate in Hz (e.g. 48000).
67    pub sample_rate: u32,
68    /// Number of output channels (1 = mono, 2 = stereo, etc.).
69    pub channels: u8,
70    /// Target bitrate in bits per second.
71    /// Ignored for lossless codecs; use 0 to select the codec default.
72    pub bitrate: u32,
73}
74
75impl AudioOnlyConfig {
76    /// Creates a new `AudioOnlyConfig` and validates the parameters.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`TranscodeError::InvalidInput`] when:
81    /// - `channels` is 0 or greater than 8
82    /// - `sample_rate` is below 8 000 Hz or above 192 000 Hz
83    pub fn new(
84        input_codec: AudioCodecId,
85        output_codec: AudioCodecId,
86        sample_rate: u32,
87        channels: u8,
88        bitrate: u32,
89    ) -> Result<Self> {
90        if channels == 0 || channels > 8 {
91            return Err(TranscodeError::InvalidInput(format!(
92                "channels must be 1–8, got {channels}"
93            )));
94        }
95        if sample_rate < 8_000 || sample_rate > 192_000 {
96            return Err(TranscodeError::InvalidInput(format!(
97                "sample_rate must be 8000–192000 Hz, got {sample_rate}"
98            )));
99        }
100        Ok(Self {
101            input_codec,
102            output_codec,
103            sample_rate,
104            channels,
105            bitrate,
106        })
107    }
108
109    /// Shortcut: stereo Opus at 128 kbps / 48 kHz.
110    ///
111    /// # Panics
112    ///
113    /// Never panics – the hard-coded values always pass validation.
114    #[must_use]
115    pub fn opus_stereo() -> Self {
116        Self::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 2, 128_000)
117            .expect("hard-coded opus_stereo config is always valid")
118    }
119
120    /// Shortcut: stereo FLAC lossless at 48 kHz.
121    ///
122    /// # Panics
123    ///
124    /// Never panics – the hard-coded values always pass validation.
125    #[must_use]
126    pub fn flac_stereo() -> Self {
127        Self::new(AudioCodecId::Pcm, AudioCodecId::Flac, 48_000, 2, 0)
128            .expect("hard-coded flac_stereo config is always valid")
129    }
130}
131
132/// Audio-only transcoder.
133///
134/// Accepts raw PCM samples (f32, -1.0..=1.0), applies channel mapping and
135/// level adjustments, and returns the processed output samples.
136///
137/// In a full pipeline this struct would wrap actual codec encode/decode calls;
138/// here it provides the configuration layer and frame-level processing hooks
139/// that the codec pipeline can call.
140pub struct AudioOnlyTranscoder {
141    config: AudioOnlyConfig,
142    /// Frame count processed so far (used for metrics).
143    frames_processed: u64,
144}
145
146impl AudioOnlyTranscoder {
147    /// Creates a new `AudioOnlyTranscoder` from a validated config.
148    #[must_use]
149    pub fn new(config: AudioOnlyConfig) -> Self {
150        Self {
151            config,
152            frames_processed: 0,
153        }
154    }
155
156    /// Returns the active configuration.
157    #[must_use]
158    pub fn config(&self) -> &AudioOnlyConfig {
159        &self.config
160    }
161
162    /// Returns the output codec name string.
163    #[must_use]
164    pub fn codec_name(&self) -> &str {
165        self.config.output_codec.name()
166    }
167
168    /// Returns the effective output bitrate in bits per second.
169    ///
170    /// If `config.bitrate` is 0 the codec default is returned.
171    #[must_use]
172    pub fn estimated_bitrate(&self) -> u32 {
173        if self.config.bitrate == 0 {
174            self.config.output_codec.default_bitrate()
175        } else {
176            self.config.bitrate
177        }
178    }
179
180    /// Number of audio frames processed since creation (or last reset).
181    #[must_use]
182    pub fn frames_processed(&self) -> u64 {
183        self.frames_processed
184    }
185
186    /// Process a block of interleaved PCM samples.
187    ///
188    /// Input samples must be interleaved with the number of channels declared in
189    /// the config, in f32 format.  Output length equals input length after
190    /// channel mapping (no sample-rate conversion is performed in this stub –
191    /// that would be delegated to `oximedia-audio::resample`).
192    ///
193    /// # Errors
194    ///
195    /// Returns [`TranscodeError::InvalidInput`] when:
196    /// - `input` length is not a multiple of `channels`
197    /// - any individual sample is NaN or infinite
198    pub fn transcode_samples(&mut self, input: &[f32]) -> Result<Vec<f32>> {
199        let ch = self.config.channels as usize;
200        if ch == 0 {
201            return Err(TranscodeError::InvalidInput(
202                "channel count must not be zero".to_string(),
203            ));
204        }
205        if input.len() % ch != 0 {
206            return Err(TranscodeError::InvalidInput(format!(
207                "input length {} is not a multiple of channel count {}",
208                input.len(),
209                ch
210            )));
211        }
212        // Validate samples
213        for (idx, &s) in input.iter().enumerate() {
214            if s.is_nan() || s.is_infinite() {
215                return Err(TranscodeError::InvalidInput(format!(
216                    "sample at index {idx} is non-finite: {s}"
217                )));
218            }
219        }
220
221        let num_frames = input.len() / ch;
222        self.frames_processed += num_frames as u64;
223
224        // Apply a simple gain stage to simulate codec processing.
225        // Real implementation would encode → decode via the relevant codec.
226        let gain = self.codec_gain_factor();
227        let output: Vec<f32> = input.iter().map(|&s| s * gain).collect();
228
229        Ok(output)
230    }
231
232    /// Reset internal counters (e.g. between programs).
233    pub fn reset(&mut self) {
234        self.frames_processed = 0;
235    }
236
237    /// Update the config on the fly (e.g. for adaptive bitrate pipelines).
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if the new config fails validation.
242    pub fn update_config(
243        &mut self,
244        input_codec: AudioCodecId,
245        output_codec: AudioCodecId,
246        sample_rate: u32,
247        channels: u8,
248        bitrate: u32,
249    ) -> Result<()> {
250        let new_cfg =
251            AudioOnlyConfig::new(input_codec, output_codec, sample_rate, channels, bitrate)?;
252        self.config = new_cfg;
253        Ok(())
254    }
255
256    // -----------------------------------------------------------------------
257    // Private helpers
258    // -----------------------------------------------------------------------
259
260    /// Returns a synthetic gain factor used by `transcode_samples` to simulate
261    /// the slight level differences introduced by lossy codecs.
262    fn codec_gain_factor(&self) -> f32 {
263        match self.config.output_codec {
264            AudioCodecId::Flac | AudioCodecId::Pcm => 1.0, // lossless
265            AudioCodecId::Opus => 0.9999,
266            AudioCodecId::Vorbis => 0.9998,
267            AudioCodecId::Mp3 => 0.9997,
268            AudioCodecId::Aac => 0.9996,
269        }
270    }
271}
272
273// ============================================================
274// Unit tests
275// ============================================================
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    // ----------------------------------------------------------
282    // AudioCodecId tests
283    // ----------------------------------------------------------
284
285    #[test]
286    fn test_codec_id_names() {
287        assert_eq!(AudioCodecId::Opus.name(), "opus");
288        assert_eq!(AudioCodecId::Flac.name(), "flac");
289        assert_eq!(AudioCodecId::Vorbis.name(), "vorbis");
290        assert_eq!(AudioCodecId::Mp3.name(), "mp3");
291        assert_eq!(AudioCodecId::Aac.name(), "aac");
292        assert_eq!(AudioCodecId::Pcm.name(), "pcm");
293    }
294
295    #[test]
296    fn test_codec_lossless_flag() {
297        assert!(AudioCodecId::Flac.is_lossless());
298        assert!(AudioCodecId::Pcm.is_lossless());
299        assert!(!AudioCodecId::Opus.is_lossless());
300        assert!(!AudioCodecId::Vorbis.is_lossless());
301        assert!(!AudioCodecId::Mp3.is_lossless());
302        assert!(!AudioCodecId::Aac.is_lossless());
303    }
304
305    #[test]
306    fn test_codec_default_bitrate() {
307        assert_eq!(AudioCodecId::Flac.default_bitrate(), 0);
308        assert_eq!(AudioCodecId::Pcm.default_bitrate(), 0);
309        assert!(AudioCodecId::Opus.default_bitrate() > 0);
310        assert!(AudioCodecId::Mp3.default_bitrate() > 0);
311    }
312
313    // ----------------------------------------------------------
314    // AudioOnlyConfig creation
315    // ----------------------------------------------------------
316
317    #[test]
318    fn test_config_valid_creation() {
319        let cfg = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 2, 128_000);
320        assert!(cfg.is_ok(), "Valid config should succeed");
321        let cfg = cfg.expect("already checked");
322        assert_eq!(cfg.sample_rate, 48_000);
323        assert_eq!(cfg.channels, 2);
324        assert_eq!(cfg.bitrate, 128_000);
325    }
326
327    #[test]
328    fn test_config_invalid_channels_zero() {
329        let result =
330            AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 0, 128_000);
331        assert!(result.is_err(), "channels=0 must fail");
332        let msg = result.expect_err("expected error").to_string();
333        assert!(
334            msg.contains("channels"),
335            "Error should mention 'channels': {msg}"
336        );
337    }
338
339    #[test]
340    fn test_config_invalid_channels_too_many() {
341        let result =
342            AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 9, 128_000);
343        assert!(result.is_err(), "channels=9 must fail");
344    }
345
346    #[test]
347    fn test_config_invalid_sample_rate_too_low() {
348        let result = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 7_999, 2, 128_000);
349        assert!(result.is_err(), "sample_rate=7999 must fail");
350        let msg = result.expect_err("expected error").to_string();
351        assert!(
352            msg.contains("sample_rate"),
353            "Error should mention 'sample_rate': {msg}"
354        );
355    }
356
357    #[test]
358    fn test_config_invalid_sample_rate_too_high() {
359        let result =
360            AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 192_001, 2, 128_000);
361        assert!(result.is_err(), "sample_rate=192001 must fail");
362    }
363
364    #[test]
365    fn test_config_boundary_sample_rates() {
366        // Minimum valid sample rate
367        let low = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Pcm, 8_000, 1, 0);
368        assert!(low.is_ok(), "sample_rate=8000 should be valid");
369
370        // Maximum valid sample rate
371        let high = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Pcm, 192_000, 1, 0);
372        assert!(high.is_ok(), "sample_rate=192000 should be valid");
373    }
374
375    #[test]
376    fn test_config_shortcuts() {
377        let opus = AudioOnlyConfig::opus_stereo();
378        assert_eq!(opus.output_codec, AudioCodecId::Opus);
379        assert_eq!(opus.channels, 2);
380        assert_eq!(opus.sample_rate, 48_000);
381
382        let flac = AudioOnlyConfig::flac_stereo();
383        assert_eq!(flac.output_codec, AudioCodecId::Flac);
384        assert_eq!(flac.channels, 2);
385        assert!(flac.output_codec.is_lossless());
386    }
387
388    // ----------------------------------------------------------
389    // AudioOnlyTranscoder tests
390    // ----------------------------------------------------------
391
392    #[test]
393    fn test_transcoder_creation() {
394        let cfg = AudioOnlyConfig::opus_stereo();
395        let t = AudioOnlyTranscoder::new(cfg);
396        assert_eq!(t.codec_name(), "opus");
397        assert_eq!(t.frames_processed(), 0);
398    }
399
400    #[test]
401    fn test_transcoder_estimated_bitrate_from_config() {
402        let cfg = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 2, 256_000)
403            .expect("valid");
404        let t = AudioOnlyTranscoder::new(cfg);
405        assert_eq!(t.estimated_bitrate(), 256_000);
406    }
407
408    #[test]
409    fn test_transcoder_estimated_bitrate_uses_default_when_zero() {
410        let cfg = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 2, 0)
411            .expect("valid");
412        let t = AudioOnlyTranscoder::new(cfg);
413        assert_eq!(t.estimated_bitrate(), AudioCodecId::Opus.default_bitrate());
414    }
415
416    #[test]
417    fn test_transcode_samples_sine_wave() {
418        let cfg = AudioOnlyConfig::opus_stereo();
419        let mut t = AudioOnlyTranscoder::new(cfg);
420
421        // Generate a 1 kHz stereo sine wave at 100 samples
422        let sample_rate = 48_000.0f32;
423        let freq = 1_000.0f32;
424        let num_frames = 100_usize;
425        let mut input = Vec::with_capacity(num_frames * 2);
426        for i in 0..num_frames {
427            let s = 0.5 * (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin();
428            input.push(s); // L
429            input.push(s); // R
430        }
431
432        let result = t.transcode_samples(&input);
433        assert!(
434            result.is_ok(),
435            "transcode_samples must succeed: {:?}",
436            result.err()
437        );
438        let output = result.expect("already checked");
439        assert_eq!(output.len(), input.len(), "Output length must match input");
440        assert_eq!(t.frames_processed(), num_frames as u64);
441    }
442
443    #[test]
444    fn test_transcode_samples_rejects_nan() {
445        let cfg = AudioOnlyConfig::opus_stereo();
446        let mut t = AudioOnlyTranscoder::new(cfg);
447        let input = vec![0.1f32, f32::NAN, 0.3, 0.4];
448        let result = t.transcode_samples(&input);
449        assert!(result.is_err(), "NaN sample must be rejected");
450    }
451
452    #[test]
453    fn test_transcode_samples_rejects_infinite() {
454        let cfg = AudioOnlyConfig::opus_stereo();
455        let mut t = AudioOnlyTranscoder::new(cfg);
456        let input = vec![0.1f32, f32::INFINITY, 0.3, 0.4];
457        let result = t.transcode_samples(&input);
458        assert!(result.is_err(), "Infinite sample must be rejected");
459    }
460
461    #[test]
462    fn test_transcode_samples_rejects_misaligned_input() {
463        let cfg = AudioOnlyConfig::opus_stereo(); // 2 channels
464        let mut t = AudioOnlyTranscoder::new(cfg);
465        // 3 samples is not a multiple of 2
466        let input = vec![0.1f32, 0.2, 0.3];
467        let result = t.transcode_samples(&input);
468        assert!(result.is_err(), "Misaligned input must be rejected");
469    }
470
471    #[test]
472    fn test_transcode_samples_empty_input_succeeds() {
473        let cfg = AudioOnlyConfig::opus_stereo();
474        let mut t = AudioOnlyTranscoder::new(cfg);
475        let result = t.transcode_samples(&[]);
476        assert!(result.is_ok());
477        assert_eq!(result.expect("ok").len(), 0);
478    }
479
480    #[test]
481    fn test_reset_clears_frame_count() {
482        let cfg = AudioOnlyConfig::opus_stereo();
483        let mut t = AudioOnlyTranscoder::new(cfg);
484        let input = vec![0.1f32, 0.2]; // 1 stereo frame
485        t.transcode_samples(&input).expect("ok");
486        assert_eq!(t.frames_processed(), 1);
487        t.reset();
488        assert_eq!(t.frames_processed(), 0);
489    }
490
491    #[test]
492    fn test_update_config_valid() {
493        let cfg = AudioOnlyConfig::opus_stereo();
494        let mut t = AudioOnlyTranscoder::new(cfg);
495        let result = t.update_config(AudioCodecId::Pcm, AudioCodecId::Flac, 44_100, 2, 0);
496        assert!(
497            result.is_ok(),
498            "update_config should succeed: {:?}",
499            result.err()
500        );
501        assert_eq!(t.codec_name(), "flac");
502    }
503
504    #[test]
505    fn test_update_config_invalid_channels() {
506        let cfg = AudioOnlyConfig::opus_stereo();
507        let mut t = AudioOnlyTranscoder::new(cfg);
508        let result = t.update_config(AudioCodecId::Pcm, AudioCodecId::Opus, 48_000, 0, 128_000);
509        assert!(result.is_err(), "Invalid channels in update should fail");
510    }
511
512    #[test]
513    fn test_lossless_output_gain_is_unity() {
514        let cfg = AudioOnlyConfig::new(AudioCodecId::Pcm, AudioCodecId::Flac, 48_000, 2, 0)
515            .expect("valid");
516        let mut t = AudioOnlyTranscoder::new(cfg);
517        let input = vec![0.5f32, 0.5f32]; // one stereo frame
518        let output = t.transcode_samples(&input).expect("ok");
519        // FLAC gain factor is 1.0 → output must equal input exactly
520        assert!(
521            (output[0] - input[0]).abs() < f32::EPSILON,
522            "FLAC should be lossless (gain=1.0)"
523        );
524    }
525}