Skip to main content

oximedia_transcode/
audio_transcode.rs

1//! Audio-specific transcoding configuration and utilities.
2//!
3//! This module provides structures and helper functions for configuring
4//! audio transcoding operations, including codec selection, bitrate estimation,
5//! loudness normalisation, and channel layout naming.
6
7#![allow(dead_code)]
8
9/// Configuration for an audio transcode operation.
10#[derive(Debug, Clone)]
11pub struct AudioTranscodeConfig {
12    /// Name of the input audio codec (e.g., `"aac"`, `"flac"`).
13    pub input_codec: String,
14    /// Name of the output audio codec (e.g., `"opus"`, `"aac"`).
15    pub output_codec: String,
16    /// Target bitrate in kilobits per second. Ignored for lossless codecs.
17    pub bitrate_kbps: u32,
18    /// Output sample rate in Hz (e.g., 48000).
19    pub sample_rate: u32,
20    /// Number of output channels (1 = mono, 2 = stereo, 6 = 5.1, etc.).
21    pub channels: u8,
22    /// Whether to apply loudness normalisation.
23    pub normalize: bool,
24    /// Target loudness in LUFS for normalisation (e.g., -23.0 for EBU R128).
25    pub target_lufs: f64,
26}
27
28impl AudioTranscodeConfig {
29    /// Creates a new config with the given codecs and basic parameters.
30    pub fn new(
31        input_codec: impl Into<String>,
32        output_codec: impl Into<String>,
33        bitrate_kbps: u32,
34        sample_rate: u32,
35        channels: u8,
36    ) -> Self {
37        Self {
38            input_codec: input_codec.into(),
39            output_codec: output_codec.into(),
40            bitrate_kbps,
41            sample_rate,
42            channels,
43            normalize: false,
44            target_lufs: -23.0,
45        }
46    }
47
48    /// Returns a config for AAC stereo at 256 kbps / 48 kHz.
49    #[must_use]
50    pub fn aac_stereo_256k() -> Self {
51        Self::new("pcm_s24le", "aac", 256, 48_000, 2)
52    }
53
54    /// Returns a config for Opus stereo at 128 kbps / 48 kHz.
55    #[must_use]
56    pub fn opus_stereo_128k() -> Self {
57        Self::new("pcm_s24le", "opus", 128, 48_000, 2)
58    }
59
60    /// Returns a config for FLAC lossless stereo at 48 kHz.
61    #[must_use]
62    pub fn flac_lossless() -> Self {
63        let mut cfg = Self::new("pcm_s24le", "flac", 0, 48_000, 2);
64        cfg.bitrate_kbps = 0; // lossless – bitrate not applicable
65        cfg
66    }
67
68    /// Enables loudness normalisation with the given LUFS target.
69    #[must_use]
70    pub fn with_normalization(mut self, target_lufs: f64) -> Self {
71        self.normalize = true;
72        self.target_lufs = target_lufs;
73        self
74    }
75
76    /// Returns `true` if the output codec is lossless.
77    #[must_use]
78    pub fn is_lossless_output(&self) -> bool {
79        is_lossless_codec(&self.output_codec)
80    }
81
82    /// Returns `true` if the configuration is considered valid.
83    ///
84    /// A valid config has a non-empty output codec, a positive sample rate,
85    /// and at least one channel.
86    #[must_use]
87    pub fn is_valid(&self) -> bool {
88        !self.output_codec.is_empty() && self.sample_rate > 0 && self.channels > 0
89    }
90}
91
92/// Represents a pending audio transcode job.
93#[derive(Debug, Clone)]
94pub struct AudioTranscodeJob {
95    /// Transcoding configuration.
96    pub config: AudioTranscodeConfig,
97    /// Path to the input audio file.
98    pub input_path: String,
99    /// Path to the output audio file.
100    pub output_path: String,
101}
102
103impl AudioTranscodeJob {
104    /// Creates a new audio transcode job.
105    pub fn new(
106        config: AudioTranscodeConfig,
107        input_path: impl Into<String>,
108        output_path: impl Into<String>,
109    ) -> Self {
110        Self {
111            config,
112            input_path: input_path.into(),
113            output_path: output_path.into(),
114        }
115    }
116
117    /// Estimates the output file size in bytes for this job.
118    ///
119    /// For lossless codecs the estimate is zero (unknown without actual encoding).
120    #[must_use]
121    pub fn estimated_output_size_bytes(&self) -> u64 {
122        if self.config.is_lossless_output() {
123            return 0;
124        }
125        0 // duration is not stored; see free function for duration-based estimate
126    }
127
128    /// Returns a human-readable summary of the job.
129    #[must_use]
130    pub fn summary(&self) -> String {
131        format!(
132            "{} → {} | {} → {} | {}ch @ {}Hz | {} kbps",
133            self.input_path,
134            self.output_path,
135            self.config.input_codec,
136            self.config.output_codec,
137            self.config.channels,
138            self.config.sample_rate,
139            self.config.bitrate_kbps,
140        )
141    }
142}
143
144/// Estimates the output file size in bytes for the given duration and bitrate.
145///
146/// Returns 0 for lossless or if bitrate is zero.
147#[must_use]
148pub fn estimate_output_size_bytes(duration_ms: u64, bitrate_kbps: u32) -> u64 {
149    if bitrate_kbps == 0 || duration_ms == 0 {
150        return 0;
151    }
152    // size = bitrate (bits/s) * duration (s) / 8 bytes/bit
153    let bits = u64::from(bitrate_kbps) * 1000 * duration_ms / 1000;
154    bits / 8
155}
156
157/// Returns the conventional channel layout name for the given channel count.
158#[must_use]
159pub fn channel_layout_name(channels: u8) -> &'static str {
160    match channels {
161        1 => "mono",
162        2 => "stereo",
163        3 => "2.1",
164        4 => "quad",
165        5 => "4.1",
166        6 => "5.1",
167        7 => "6.1",
168        8 => "7.1",
169        _ => "unknown",
170    }
171}
172
173/// Returns `true` if the codec name is a known lossless audio codec.
174#[must_use]
175pub fn is_lossless_codec(codec: &str) -> bool {
176    matches!(
177        codec.to_lowercase().as_str(),
178        "flac"
179            | "alac"
180            | "pcm_s16le"
181            | "pcm_s16be"
182            | "pcm_s24le"
183            | "pcm_s24be"
184            | "pcm_s32le"
185            | "pcm_s32be"
186            | "pcm_f32le"
187            | "pcm_f64le"
188            | "wavpack"
189            | "tta"
190            | "mlp"
191            | "truehd"
192    )
193}
194
195/// Returns the typical maximum bitrate in kbps for a given codec at the given channel count.
196///
197/// These are approximate reference values, not hard limits.
198#[must_use]
199pub fn typical_max_bitrate_kbps(codec: &str, channels: u8) -> u32 {
200    let per_channel: u32 = match codec.to_lowercase().as_str() {
201        "opus" => 64,
202        "aac" | "aac_lc" | "he_aac" => 128,
203        "mp3" => 160,
204        "vorbis" => 96,
205        "ac3" | "eac3" => 192,
206        "dts" => 256,
207        _ => 128,
208    };
209    per_channel * u32::from(channels)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_aac_stereo_256k_config() {
218        let cfg = AudioTranscodeConfig::aac_stereo_256k();
219        assert_eq!(cfg.output_codec, "aac");
220        assert_eq!(cfg.bitrate_kbps, 256);
221        assert_eq!(cfg.sample_rate, 48_000);
222        assert_eq!(cfg.channels, 2);
223        assert!(!cfg.is_lossless_output());
224    }
225
226    #[test]
227    fn test_opus_stereo_128k_config() {
228        let cfg = AudioTranscodeConfig::opus_stereo_128k();
229        assert_eq!(cfg.output_codec, "opus");
230        assert_eq!(cfg.bitrate_kbps, 128);
231        assert_eq!(cfg.channels, 2);
232    }
233
234    #[test]
235    fn test_flac_lossless_config() {
236        let cfg = AudioTranscodeConfig::flac_lossless();
237        assert_eq!(cfg.output_codec, "flac");
238        assert_eq!(cfg.bitrate_kbps, 0);
239        assert!(cfg.is_lossless_output());
240    }
241
242    #[test]
243    fn test_config_with_normalization() {
244        let cfg = AudioTranscodeConfig::aac_stereo_256k().with_normalization(-16.0);
245        assert!(cfg.normalize);
246        assert!((cfg.target_lufs - -16.0).abs() < 1e-9);
247    }
248
249    #[test]
250    fn test_config_is_valid() {
251        let cfg = AudioTranscodeConfig::aac_stereo_256k();
252        assert!(cfg.is_valid());
253
254        let bad = AudioTranscodeConfig::new("pcm", "", 256, 48_000, 2);
255        assert!(!bad.is_valid());
256
257        let bad_rate = AudioTranscodeConfig::new("pcm", "aac", 256, 0, 2);
258        assert!(!bad_rate.is_valid());
259
260        let bad_ch = AudioTranscodeConfig::new("pcm", "aac", 256, 48_000, 0);
261        assert!(!bad_ch.is_valid());
262    }
263
264    #[test]
265    fn test_estimate_output_size_bytes() {
266        // 128 kbps for 10 seconds = 128000 * 10 / 8 = 160 000 bytes
267        assert_eq!(estimate_output_size_bytes(10_000, 128), 160_000);
268    }
269
270    #[test]
271    fn test_estimate_output_size_bytes_zero_bitrate() {
272        assert_eq!(estimate_output_size_bytes(10_000, 0), 0);
273    }
274
275    #[test]
276    fn test_estimate_output_size_bytes_zero_duration() {
277        assert_eq!(estimate_output_size_bytes(0, 256), 0);
278    }
279
280    #[test]
281    fn test_channel_layout_name() {
282        assert_eq!(channel_layout_name(1), "mono");
283        assert_eq!(channel_layout_name(2), "stereo");
284        assert_eq!(channel_layout_name(6), "5.1");
285        assert_eq!(channel_layout_name(8), "7.1");
286        assert_eq!(channel_layout_name(10), "unknown");
287    }
288
289    #[test]
290    fn test_is_lossless_codec_known_lossless() {
291        assert!(is_lossless_codec("flac"));
292        assert!(is_lossless_codec("FLAC"));
293        assert!(is_lossless_codec("alac"));
294        assert!(is_lossless_codec("pcm_s16le"));
295        assert!(is_lossless_codec("wavpack"));
296        assert!(is_lossless_codec("truehd"));
297    }
298
299    #[test]
300    fn test_is_lossless_codec_known_lossy() {
301        assert!(!is_lossless_codec("aac"));
302        assert!(!is_lossless_codec("opus"));
303        assert!(!is_lossless_codec("mp3"));
304        assert!(!is_lossless_codec("vorbis"));
305        assert!(!is_lossless_codec("ac3"));
306    }
307
308    #[test]
309    fn test_typical_max_bitrate_stereo() {
310        let opus_stereo = typical_max_bitrate_kbps("opus", 2);
311        assert_eq!(opus_stereo, 128);
312
313        let aac_51 = typical_max_bitrate_kbps("aac", 6);
314        assert_eq!(aac_51, 768);
315    }
316
317    #[test]
318    fn test_audio_transcode_job_summary() {
319        let cfg = AudioTranscodeConfig::aac_stereo_256k();
320        let job = AudioTranscodeJob::new(cfg, "input.mxf", "output.m4a");
321        let summary = job.summary();
322        assert!(summary.contains("input.mxf"));
323        assert!(summary.contains("output.m4a"));
324        assert!(summary.contains("aac"));
325    }
326}