Skip to main content

oximedia_transcode/
audio_channel_map.rs

1//! Audio channel layout mapping and transcode parameters.
2//!
3//! Provides channel layout enumeration, passthrough detection, gain computation,
4//! and parameter validation for audio transcoding stages.
5
6#![allow(dead_code)]
7
8use serde::{Deserialize, Serialize};
9
10/// Standard audio channel layouts.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum AudioLayout {
13    /// Single mono channel.
14    Mono,
15    /// Two stereo channels (L, R).
16    Stereo,
17    /// 2.1 channels (L, R, LFE).
18    TwoPointOne,
19    /// 4.0 surround (L, R, Ls, Rs).
20    Quad,
21    /// 5.0 surround (L, R, C, Ls, Rs).
22    FivePointZero,
23    /// 5.1 surround (L, R, C, LFE, Ls, Rs).
24    FivePointOne,
25    /// 7.1 surround (L, R, C, LFE, Lss, Rss, Lrs, Rrs).
26    SevenPointOne,
27}
28
29impl AudioLayout {
30    /// Number of channels in this layout.
31    #[must_use]
32    pub fn channel_count(self) -> u8 {
33        match self {
34            Self::Mono => 1,
35            Self::Stereo => 2,
36            Self::TwoPointOne => 3,
37            Self::Quad => 4,
38            Self::FivePointZero => 5,
39            Self::FivePointOne => 6,
40            Self::SevenPointOne => 8,
41        }
42    }
43
44    /// Returns `true` if the layout has a dedicated LFE (sub-woofer) channel.
45    #[must_use]
46    pub fn has_lfe(self) -> bool {
47        matches!(
48            self,
49            Self::TwoPointOne | Self::FivePointOne | Self::SevenPointOne
50        )
51    }
52
53    /// Returns a human-readable label.
54    #[must_use]
55    pub fn label(self) -> &'static str {
56        match self {
57            Self::Mono => "mono",
58            Self::Stereo => "stereo",
59            Self::TwoPointOne => "2.1",
60            Self::Quad => "4.0",
61            Self::FivePointZero => "5.0",
62            Self::FivePointOne => "5.1",
63            Self::SevenPointOne => "7.1",
64        }
65    }
66}
67
68/// Parameters that govern how a single audio stream is transcoded.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AudioTranscodeParams {
71    /// Input channel layout.
72    pub input_layout: AudioLayout,
73    /// Desired output channel layout.
74    pub output_layout: AudioLayout,
75    /// Input sample rate in Hz.
76    pub input_sample_rate: u32,
77    /// Desired output sample rate in Hz.
78    pub output_sample_rate: u32,
79    /// Target audio bitrate in bits per second.
80    pub target_bitrate_bps: u32,
81    /// Linear gain to apply during transcode (1.0 = no change).
82    pub gain_linear: f32,
83    /// Whether to normalise loudness to EBU R128.
84    pub normalise_loudness: bool,
85}
86
87impl Default for AudioTranscodeParams {
88    fn default() -> Self {
89        Self {
90            input_layout: AudioLayout::Stereo,
91            output_layout: AudioLayout::Stereo,
92            input_sample_rate: 48_000,
93            output_sample_rate: 48_000,
94            target_bitrate_bps: 128_000,
95            gain_linear: 1.0,
96            normalise_loudness: false,
97        }
98    }
99}
100
101impl AudioTranscodeParams {
102    /// Create default params for a stereo stream.
103    #[must_use]
104    pub fn stereo() -> Self {
105        Self::default()
106    }
107
108    /// Create params for a mono downmix.
109    #[must_use]
110    pub fn mono_downmix() -> Self {
111        Self {
112            output_layout: AudioLayout::Mono,
113            ..Self::default()
114        }
115    }
116
117    /// Returns `true` when the output layout and sample rate match the input
118    /// and no gain or loudness normalisation is applied — i.e. pure passthrough.
119    #[must_use]
120    pub fn is_passthrough(&self) -> bool {
121        self.input_layout == self.output_layout
122            && self.input_sample_rate == self.output_sample_rate
123            && (self.gain_linear - 1.0).abs() < f32::EPSILON
124            && !self.normalise_loudness
125    }
126
127    /// Returns `true` if the configuration is logically valid.
128    #[must_use]
129    pub fn is_valid(&self) -> bool {
130        self.input_sample_rate > 0
131            && self.output_sample_rate > 0
132            && self.target_bitrate_bps > 0
133            && self.gain_linear >= 0.0
134    }
135}
136
137/// Maps individual input channels to output channels and applies per-channel gain.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AudioChannelMap {
140    input_layout: AudioLayout,
141    output_layout: AudioLayout,
142    /// Routing: `output_channel_index` -> (`input_channel_index`, `gain_factor`).
143    routes: Vec<(usize, f32)>,
144}
145
146impl AudioChannelMap {
147    /// Create an identity map (each input channel routes to the same output channel).
148    #[must_use]
149    pub fn identity(layout: AudioLayout) -> Self {
150        let n = layout.channel_count() as usize;
151        let routes = (0..n).map(|i| (i, 1.0_f32)).collect();
152        Self {
153            input_layout: layout,
154            output_layout: layout,
155            routes,
156        }
157    }
158
159    /// Create a downmix map from stereo to mono (equal-power mix of L+R).
160    #[must_use]
161    pub fn stereo_to_mono() -> Self {
162        Self {
163            input_layout: AudioLayout::Stereo,
164            output_layout: AudioLayout::Mono,
165            // Mono = 0.707 * L + 0.707 * R  (from channel 0 and channel 1)
166            routes: vec![(0, 0.707_107), (1, 0.707_107)],
167        }
168    }
169
170    /// Compute the gain in dB for a given output channel index.
171    ///
172    /// Returns `None` if the output channel index is out of range.
173    #[allow(clippy::cast_precision_loss)]
174    #[must_use]
175    pub fn compute_gain_db(&self, output_channel: usize) -> Option<f64> {
176        let (_, gain_linear) = self.routes.get(output_channel)?;
177        if *gain_linear <= 0.0 {
178            return Some(f64::NEG_INFINITY);
179        }
180        // 20 * log10(linear_gain)
181        let db = 20.0 * f64::from(*gain_linear).log10();
182        Some(db)
183    }
184
185    /// Validate that the map is consistent with the declared layouts.
186    ///
187    /// For downmix maps (e.g. stereo→mono), the number of routes equals the
188    /// number of *input* channels being mixed, which may exceed the output
189    /// channel count.  We therefore only require that the route count is at
190    /// least the output channel count.
191    pub fn validate_params(&self) -> Result<(), String> {
192        let expected_out = self.output_layout.channel_count() as usize;
193        if self.routes.len() < expected_out {
194            return Err(format!(
195                "route count {} is less than output channel count {}",
196                self.routes.len(),
197                expected_out
198            ));
199        }
200        let max_in = self.input_layout.channel_count() as usize;
201        for (ch_idx, _) in &self.routes {
202            if *ch_idx >= max_in {
203                return Err(format!(
204                    "input channel index {ch_idx} out of range (input has {max_in} channels)"
205                ));
206            }
207        }
208        Ok(())
209    }
210
211    /// Number of output channels.
212    #[must_use]
213    pub fn output_channel_count(&self) -> usize {
214        self.output_layout.channel_count() as usize
215    }
216
217    /// Apply the channel map to an input sample buffer.
218    ///
219    /// `input` is interleaved samples (f32), `output` must be pre-allocated
220    /// with `frame_size * output_channels` entries.
221    pub fn apply(&self, input: &[f32], output: &mut [f32], frame_size: usize) {
222        let in_ch = self.input_layout.channel_count() as usize;
223        let out_ch = self.output_layout.channel_count() as usize;
224        for frame in 0..frame_size {
225            for (out_idx, (in_idx, gain)) in self.routes.iter().enumerate() {
226                let in_pos = frame * in_ch + in_idx;
227                let out_pos = frame * out_ch + out_idx;
228                if in_pos < input.len() && out_pos < output.len() {
229                    output[out_pos] += input[in_pos] * gain;
230                }
231            }
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_mono_channel_count() {
242        assert_eq!(AudioLayout::Mono.channel_count(), 1);
243    }
244
245    #[test]
246    fn test_stereo_channel_count() {
247        assert_eq!(AudioLayout::Stereo.channel_count(), 2);
248    }
249
250    #[test]
251    fn test_five_one_channel_count() {
252        assert_eq!(AudioLayout::FivePointOne.channel_count(), 6);
253    }
254
255    #[test]
256    fn test_seven_one_channel_count() {
257        assert_eq!(AudioLayout::SevenPointOne.channel_count(), 8);
258    }
259
260    #[test]
261    fn test_has_lfe() {
262        assert!(AudioLayout::FivePointOne.has_lfe());
263        assert!(AudioLayout::TwoPointOne.has_lfe());
264        assert!(!AudioLayout::Stereo.has_lfe());
265        assert!(!AudioLayout::Quad.has_lfe());
266    }
267
268    #[test]
269    fn test_labels_non_empty() {
270        let layouts = [
271            AudioLayout::Mono,
272            AudioLayout::Stereo,
273            AudioLayout::TwoPointOne,
274            AudioLayout::Quad,
275            AudioLayout::FivePointZero,
276            AudioLayout::FivePointOne,
277            AudioLayout::SevenPointOne,
278        ];
279        for l in layouts {
280            assert!(!l.label().is_empty());
281        }
282    }
283
284    #[test]
285    fn test_params_is_passthrough_default() {
286        let p = AudioTranscodeParams::default();
287        assert!(p.is_passthrough());
288    }
289
290    #[test]
291    fn test_params_not_passthrough_different_layout() {
292        let p = AudioTranscodeParams {
293            output_layout: AudioLayout::Mono,
294            ..AudioTranscodeParams::default()
295        };
296        assert!(!p.is_passthrough());
297    }
298
299    #[test]
300    fn test_params_not_passthrough_with_gain() {
301        let p = AudioTranscodeParams {
302            gain_linear: 1.5,
303            ..AudioTranscodeParams::default()
304        };
305        assert!(!p.is_passthrough());
306    }
307
308    #[test]
309    fn test_params_is_valid_default() {
310        assert!(AudioTranscodeParams::default().is_valid());
311    }
312
313    #[test]
314    fn test_params_invalid_zero_sample_rate() {
315        let p = AudioTranscodeParams {
316            input_sample_rate: 0,
317            ..AudioTranscodeParams::default()
318        };
319        assert!(!p.is_valid());
320    }
321
322    #[test]
323    fn test_identity_map_validate() {
324        let m = AudioChannelMap::identity(AudioLayout::Stereo);
325        assert!(m.validate_params().is_ok());
326    }
327
328    #[test]
329    fn test_stereo_to_mono_validate() {
330        let m = AudioChannelMap::stereo_to_mono();
331        assert!(m.validate_params().is_ok());
332    }
333
334    #[test]
335    fn test_compute_gain_db_unity() {
336        let m = AudioChannelMap::identity(AudioLayout::Stereo);
337        let db = m.compute_gain_db(0).expect("should succeed in test");
338        assert!(
339            (db - 0.0).abs() < 1e-9,
340            "unity gain should be 0 dB, got {db}"
341        );
342    }
343
344    #[test]
345    fn test_compute_gain_db_stereo_to_mono() {
346        let m = AudioChannelMap::stereo_to_mono();
347        let db = m.compute_gain_db(0).expect("should succeed in test");
348        // 20*log10(0.707) ≈ -3.01 dB
349        assert!((db + 3.01).abs() < 0.1, "expected ~-3 dB, got {db}");
350    }
351
352    #[test]
353    fn test_compute_gain_db_out_of_range() {
354        let m = AudioChannelMap::identity(AudioLayout::Mono);
355        assert!(m.compute_gain_db(5).is_none());
356    }
357
358    #[test]
359    fn test_apply_identity() {
360        let m = AudioChannelMap::identity(AudioLayout::Stereo);
361        let input = vec![0.5_f32, 0.8, 0.3, 0.1];
362        let mut output = vec![0.0_f32; 4];
363        m.apply(&input, &mut output, 2);
364        assert!((output[0] - 0.5).abs() < 1e-6);
365        assert!((output[1] - 0.8).abs() < 1e-6);
366    }
367
368    #[test]
369    fn test_output_channel_count() {
370        let m = AudioChannelMap::stereo_to_mono();
371        assert_eq!(m.output_channel_count(), 1);
372    }
373}