Skip to main content

koan_core/audio/
opus.rs

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