Skip to main content

koan_core/audio/
opus.rs

1//! Opus decoding bridge — wraps `opus-rs` to decode packets from
2//! Symphonia's Ogg demuxer. Symphonia can identify Opus streams but has no
3//! codec implementation; this module fills that gap.
4//!
5//! Opus always decodes to 48 kHz, regardless of the internal sample rate.
6//! Channel count and pre-skip come from the `OpusHead` identification header,
7//! which every demuxer we use hands over as `extra_data` rather than a packet.
8
9use opus_rs::OpusDecoder;
10use std::panic::{AssertUnwindSafe, catch_unwind};
11use symphonia::core::codecs::audio::AudioCodecParameters;
12
13/// The longest Opus frame, 120 ms at 48 kHz, in samples per channel.
14const MAX_FRAME: usize = 5760;
15
16/// Errors from the Opus decode bridge.
17#[derive(Debug, thiserror::Error)]
18pub enum OpusError {
19    #[error("opus decoder init failed: {0}")]
20    Init(String),
21    #[error("opus decode error: {0}")]
22    Decode(String),
23    #[error("invalid opus header")]
24    InvalidHeader,
25    #[error("opus decoder panicked")]
26    Panicked,
27}
28
29/// State for decoding an Opus stream via Symphonia packets.
30pub struct OpusBridge {
31    decoder: OpusDecoder,
32    channels: usize,
33    /// Reusable buffer for decoded f32 PCM (interleaved).
34    pcm_buf: Vec<f32>,
35    /// Samples per channel to skip from the start (Opus pre-skip).
36    pre_skip: u32,
37    /// Samples per channel already skipped.
38    skipped: u32,
39}
40
41/// Opus identification header layout (first 19 bytes minimum):
42///   0..8   "OpusHead"
43///   8      version (1)
44///   9      channel count
45///  10..12  pre-skip (little-endian u16)
46///  12..16  input sample rate (little-endian u32, informational only)
47///  16..18  output gain (little-endian i16)
48///  18      channel mapping family
49const OPUS_HEAD_MAGIC: &[u8] = b"OpusHead";
50const OPUS_TAGS_MAGIC: &[u8] = b"OpusTags";
51const OPUS_HEAD_MIN_LEN: usize = 19;
52
53/// Whether a packet is an Opus header rather than audio.
54///
55/// Which packets reach us depends on the demuxer: Symphonia's Ogg reader
56/// consumes `OpusHead` and `OpusTags` into `extra_data` and hands over only
57/// audio, while Matroska carries them in CodecPrivate and never emits them at
58/// all. Recognising them by magic covers a reader that does pass them through
59/// without costing audio to one that doesn't.
60fn is_header_packet(data: &[u8]) -> bool {
61    data.len() >= 8 && (&data[..8] == OPUS_HEAD_MAGIC || &data[..8] == OPUS_TAGS_MAGIC)
62}
63
64/// Parse the Opus identification header to extract channel count and pre-skip.
65fn parse_opus_head(data: &[u8]) -> Result<(usize, u32), OpusError> {
66    if data.len() < OPUS_HEAD_MIN_LEN || &data[..8] != OPUS_HEAD_MAGIC {
67        return Err(OpusError::InvalidHeader);
68    }
69    let channels = data[9] as usize;
70    let pre_skip = u16::from_le_bytes([data[10], data[11]]) as u32;
71    Ok((channels, pre_skip))
72}
73
74impl OpusBridge {
75    /// Create a new Opus decoder from Symphonia codec parameters.
76    ///
77    /// The `extra_data` in `AudioCodecParameters` should contain the Opus
78    /// identification header (OpusHead). If not present, falls back to
79    /// channel count from codec params.
80    pub fn new(params: &AudioCodecParameters) -> Result<Self, OpusError> {
81        // Try to get channel count and pre-skip from the OpusHead extra data.
82        let (channels, pre_skip) = if let Some(extra) = &params.extra_data {
83            parse_opus_head(extra)?
84        } else {
85            // Fallback: use codec params channel count, assume no pre-skip.
86            let ch = params.channels.as_ref().map(|c| c.count()).unwrap_or(2);
87            (ch, 0)
88        };
89
90        if channels == 0 || channels > 2 {
91            // opus-rs only supports mono/stereo. Multistream would need
92            // OpusMultistreamDecoder, which is not handled.
93            return Err(OpusError::Init(format!(
94                "unsupported channel count: {channels} (only mono/stereo supported)"
95            )));
96        }
97
98        let decoder = OpusDecoder::new(48000, channels).map_err(|e| OpusError::Init(e.into()))?;
99
100        let max_samples = MAX_FRAME * channels;
101        let pcm_buf = vec![0.0f32; max_samples];
102
103        Ok(Self {
104            decoder,
105            channels,
106            pcm_buf,
107            pre_skip,
108            skipped: 0,
109        })
110    }
111
112    /// Channel count for this stream.
113    pub fn channels(&self) -> usize {
114        self.channels
115    }
116
117    /// Decode one Symphonia packet. Returns a slice of interleaved f32 PCM
118    /// samples, or an empty slice for header/comment packets.
119    ///
120    /// Handles pre-skip trimming: the first N samples (per Opus spec) are
121    /// silently discarded.
122    pub fn decode_packet(&mut self, data: &[u8]) -> Result<&[f32], OpusError> {
123        if is_header_packet(data) {
124            return Ok(&[]);
125        }
126
127        // A port of libopus rather than libopus itself: contain a panic, rather
128        // than let one bad packet take the decode thread with it.
129        let decoder = &mut self.decoder;
130        let pcm_buf = &mut self.pcm_buf;
131        let frames_per_channel = match catch_unwind(AssertUnwindSafe(|| {
132            decoder.decode(data, MAX_FRAME, pcm_buf)
133        })) {
134            Ok(Ok(frames)) => frames,
135            Ok(Err(e)) => return Err(OpusError::Decode(e.into())),
136            Err(_) => return Err(OpusError::Panicked),
137        };
138
139        let total_samples = frames_per_channel * self.channels;
140
141        // Handle pre-skip: discard the first `pre_skip` samples per channel.
142        let start = if self.skipped < self.pre_skip {
143            let remaining = (self.pre_skip - self.skipped) as usize;
144            let skip_frames = remaining.min(frames_per_channel);
145            self.skipped += skip_frames as u32;
146            skip_frames * self.channels
147        } else {
148            0
149        };
150
151        Ok(&self.pcm_buf[start..total_samples])
152    }
153
154    /// Start decoding afresh after a seek. Landing `at_start`, the stream's
155    /// pre-skip is still ahead and is dropped again; anywhere else it is not.
156    pub fn reset(&mut self, at_start: bool) {
157        // opus-rs has no reset; a fresh decoder is the same state and cheap.
158        if let Ok(decoder) = OpusDecoder::new(48000, self.channels) {
159            self.decoder = decoder;
160        }
161        self.skipped = if at_start { 0 } else { self.pre_skip };
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_parse_opus_head_valid() {
171        // Minimal valid OpusHead: stereo, pre-skip=312
172        let mut header = vec![0u8; 19];
173        header[..8].copy_from_slice(b"OpusHead");
174        header[8] = 1; // version
175        header[9] = 2; // channels
176        header[10] = 0x38; // pre_skip = 312 (0x0138)
177        header[11] = 0x01;
178        // rest is zeros (sample rate, gain, mapping family)
179
180        let (channels, pre_skip) = parse_opus_head(&header).unwrap();
181        assert_eq!(channels, 2);
182        assert_eq!(pre_skip, 312);
183    }
184
185    #[test]
186    fn test_parse_opus_head_mono() {
187        let mut header = vec![0u8; 19];
188        header[..8].copy_from_slice(b"OpusHead");
189        header[8] = 1;
190        header[9] = 1; // mono
191        header[10] = 0x00;
192        header[11] = 0x00;
193
194        let (channels, pre_skip) = parse_opus_head(&header).unwrap();
195        assert_eq!(channels, 1);
196        assert_eq!(pre_skip, 0);
197    }
198
199    #[test]
200    fn test_parse_opus_head_invalid_magic() {
201        let header = b"NotOpusHead_padding";
202        assert!(parse_opus_head(header).is_err());
203    }
204
205    #[test]
206    fn test_parse_opus_head_too_short() {
207        let header = b"OpusHea"; // 7 bytes
208        assert!(parse_opus_head(header).is_err());
209    }
210
211    #[test]
212    fn test_opus_bridge_new_stereo() {
213        // Build minimal CodecParameters with OpusHead extra data.
214        let mut header = vec![0u8; 19];
215        header[..8].copy_from_slice(b"OpusHead");
216        header[8] = 1;
217        header[9] = 2; // stereo
218        header[10] = 0x38;
219        header[11] = 0x01; // pre_skip=312
220
221        let mut params = AudioCodecParameters::new();
222        params.with_extra_data(header.into_boxed_slice());
223
224        let bridge = OpusBridge::new(&params).unwrap();
225        assert_eq!(bridge.channels(), 2);
226    }
227
228    #[test]
229    fn pre_skip_is_dropped_at_the_start_and_only_there() {
230        let mut header = vec![0u8; 19];
231        header[..8].copy_from_slice(b"OpusHead");
232        header[8] = 1;
233        header[9] = 2;
234        header[10..12].copy_from_slice(&312u16.to_le_bytes());
235        let mut params = AudioCodecParameters::new();
236        params.with_extra_data(header.into_boxed_slice());
237        let mut bridge = OpusBridge::new(&params).unwrap();
238
239        // One 20 ms stereo frame of a tone.
240        let pcm: Vec<f32> = (0..960 * 2)
241            .map(|i| ((i / 2) as f32 * 0.05).sin() * 0.5)
242            .collect();
243        let mut encoder = opus_rs::OpusEncoder::new(48000, 2, opus_rs::Application::Audio).unwrap();
244        let mut packet = vec![0u8; 1500];
245        let len = encoder.encode(&pcm, 960, &mut packet).unwrap();
246        let packet = &packet[..len];
247
248        let frames = |bridge: &mut OpusBridge| bridge.decode_packet(packet).unwrap().len() / 2;
249        assert_eq!(frames(&mut bridge), 960 - 312);
250        assert_eq!(frames(&mut bridge), 960);
251        bridge.reset(false);
252        assert_eq!(frames(&mut bridge), 960);
253        bridge.reset(true);
254        assert_eq!(frames(&mut bridge), 960 - 312);
255    }
256
257    #[test]
258    fn test_header_packets_recognised_by_magic() {
259        let mut head = vec![0u8; 19];
260        head[..8].copy_from_slice(b"OpusHead");
261        assert!(is_header_packet(&head));
262
263        let mut tags = vec![0u8; 32];
264        tags[..8].copy_from_slice(b"OpusTags");
265        assert!(is_header_packet(&tags));
266    }
267
268    #[test]
269    fn test_audio_packets_are_not_treated_as_headers() {
270        // A TOC byte of 0x4F ('O') is legal, so the check must match the whole
271        // magic — matching the first byte alone would eat audio.
272        assert!(!is_header_packet(&[
273            0x4F, 0x70, 0x75, 0x11, 0x22, 0x33, 0x44, 0x55
274        ]));
275        assert!(!is_header_packet(&[0xFC, 0x01, 0x02, 0x03]));
276        assert!(!is_header_packet(&[]));
277    }
278
279    #[test]
280    fn test_opus_bridge_rejects_multichannel() {
281        let mut header = vec![0u8; 19];
282        header[..8].copy_from_slice(b"OpusHead");
283        header[8] = 1;
284        header[9] = 6; // 5.1 surround — not supported
285        header[10] = 0x00;
286        header[11] = 0x00;
287
288        let mut params = AudioCodecParameters::new();
289        params.with_extra_data(header.into_boxed_slice());
290
291        assert!(OpusBridge::new(&params).is_err());
292    }
293}