Skip to main content

codec/audio/encode/
opus.rs

1//! Opus encoder wrapping `audiopus` (libopus FFI; libopus is BSD,
2//! audiopus is ISC). Squad-23's MP4 mux side consumes the packets +
3//! `extra_data()` (dOps body per RFC 7845 §4.5) + `pre_skip()` (samples
4//! at 48 kHz queried via `OPUS_GET_LOOKAHEAD`).
5//!
6//! Constraints we enforce on the Opus side:
7//! - Native sample rates are 8/12/16/24/48 kHz only. We always run the
8//!   internal libopus encoder at 48 kHz and resample the input ourselves
9//!   via [`AudioResampler`] when the source isn't 48 k. This keeps
10//!   pre_skip semantics simple (always reported in 48 kHz ticks per the
11//!   RFC) and means the dOps `InputSampleRate` field cleanly reflects
12//!   the original source rate.
13//! - Frame sizes must be 2.5/5/10/20/40/60 ms. We use 20 ms = 960
14//!   samples at 48 kHz. This is libopus's default and matches what
15//!   browsers / WebRTC expect.
16//! - Channels: 1 (mono) and 2 (stereo) use the regular `audiopus::coder::Encoder`
17//!   API. 3..=8 channels (3.0 / quad / 5.0 / 5.1 / 6.1 / 7.1) use the
18//!   libopus Multistream API via `audiopus_sys` FFI (Squad-28). Channel
19//!   counts above 8 return [`AudioError::Unsupported`] — RFC 7845
20//!   §5.1.1.2 only specifies channel-mapping family 1 for 1..=8 channels.
21//!
22//! Defaults
23//! --------
24//! - 96 kbps for stereo, 64 kbps for mono if the caller passes 0.
25//!   Multichannel: 64 kbps per uncoupled stream + 96 kbps per coupled
26//!   stream (so 5.1 = 96 + 96 + 64 + 64 = 320 kbps total) — well above
27//!   transparency for music/speech (Opus reaches transparency around
28//!   64 kbps stereo for music).
29//! - Application = `Audio` (vs Voip / LowDelay): tuned for fidelity
30//!   over latency. Latency from a 20 ms frame size + ~6.5 ms libopus
31//!   lookahead is ~26 ms one-way which is fine for offline transcode.
32//!
33//! Multistream API (Squad-28)
34//! --------------------------
35//! `audiopus 0.3.0-rc.0` ships a `multistream = []` Cargo feature that's
36//! a stub — it gates no Rust code (the high-level wrapper just doesn't
37//! exist for the multistream side in this crate version). We call the
38//! underlying FFI symbols directly via `audiopus::ffi::*` (which re-exports
39//! `audiopus_sys 0.2.2`'s `opus_multistream_encoder_*` functions). The
40//! channel-mapping family 1 layouts we wire follow RFC 7845 §5.1.1.2
41//! verbatim (3.0 / quad / 5.0 / 5.1 / 6.1 / 7.1).
42
43use audiopus::Application;
44use audiopus::Bitrate;
45use audiopus::Channels as OpusChannels;
46use audiopus::SampleRate;
47use audiopus::coder::Encoder as OpusEncoderInner;
48use audiopus::ffi;
49
50use std::ffi::c_int;
51use std::ptr;
52
53use crate::audio::resample::AudioResampler;
54use crate::audio::{
55    AudioCodec, AudioEncoder, AudioEncoderConfig, AudioError, AudioFrame, EncodedAudioPacket,
56};
57
58/// 20 ms frame at 48 kHz = 960 samples per channel. This is the
59/// default/recommended Opus frame size.
60const OPUS_FRAME_SAMPLES_48K: usize = 960;
61/// Internal encoder rate we always run libopus at. Resample to here
62/// from any source rate. Per RFC 7845, pre_skip is always counted in
63/// 48 kHz ticks regardless.
64const OPUS_INTERNAL_RATE: u32 = 48_000;
65/// Maximum bytes per Opus packet per RFC 6716 §3.4 — actual bound is
66/// 1275 for 60ms VBR + multistream overhead; we round up to 4000 as
67/// audiopus does, which gives a comfortable margin. For multistream
68/// the per-frame budget scales with stream count; we use the standard
69/// libopus bound of 1275 bytes per stream and cap at 8 streams = 10200
70/// bytes (rounded up to 16384 for headroom).
71const OPUS_MAX_PACKET_BYTES: usize = 4000;
72const OPUS_MAX_MS_PACKET_BYTES: usize = 16_384;
73/// Default bitrates per channel-count, in bits/second.
74const DEFAULT_BITRATE_MONO: u32 = 64_000;
75const DEFAULT_BITRATE_STEREO: u32 = 96_000;
76
77/// Channel-mapping family 1 surround layouts per RFC 7845 §5.1.1.2.
78/// Each entry: (streams, coupled_streams, channel_mapping).
79/// `streams` = total internal Opus streams.
80/// `coupled_streams` = number of those streams that are stereo (2-channel).
81/// `channel_mapping[i]` = which encoder stream the i-th *output* channel
82/// pulls from. Indices 0..coupled*2 belong to the coupled (stereo)
83/// streams (each coupled stream consumes two consecutive indices); the
84/// remaining indices coupled*2..streams+coupled belong to the mono
85/// (uncoupled) streams. Total mapping length == channel count.
86fn surround_mapping_family_1(channels: u8) -> Result<(u8, u8, &'static [u8]), AudioError> {
87    // RFC 7845 §5.1.1.2 — Vorbis channel order on input:
88    //   3 (3.0):   L, R, C
89    //   4 (quad):  FL, FR, BL, BR
90    //   5 (5.0):   FL, FR, C, BL, BR
91    //   6 (5.1):   FL, FR, C, LFE, BL, BR
92    //   7 (6.1):   FL, FR, C, LFE, BC, SL, SR
93    //   8 (7.1):   FL, FR, C, LFE, BL, BR, SL, SR
94    //
95    // Tuples below `(streams, coupled, mapping)` mirror libopus's
96    // authoritative `vorbis_mappings[]` table in
97    // `opus/src/opus_multistream_encoder.c:53-62`. The note in the
98    // Squad-28 task spec listed `7 channels: streams=4, coupled=2`,
99    // but libopus's reference table has `streams=4, coupled=3` (and
100    // the mapping `[0, 4, 1, 2, 3, 5, 6]` requires stream index 6 to
101    // exist, which only happens with coupled=3 → max valid stream
102    // index = streams + coupled - 1 = 6). Using the libopus values
103    // makes the multistream-create call succeed.
104    //
105    // The mapping below answers "which coded stream is each output channel?".
106    // Encoder packs coupled (stereo) pairs first (taking 2 indices each),
107    // then mono streams (1 index each). Total encoder channels =
108    // streams + coupled.
109    match channels {
110        3 => Ok((2, 1, &[0, 2, 1])),
111        4 => Ok((2, 2, &[0, 1, 2, 3])),
112        5 => Ok((3, 2, &[0, 4, 1, 2, 3])),
113        6 => Ok((4, 2, &[0, 4, 1, 2, 3, 5])),
114        7 => Ok((4, 3, &[0, 4, 1, 2, 3, 5, 6])),
115        8 => Ok((5, 3, &[0, 6, 1, 2, 3, 4, 5, 7])),
116        _ => Err(AudioError::Unsupported(format!(
117            "Opus surround mapping family 1 only defined for 3..=8 channels; got {channels}"
118        ))),
119    }
120}
121
122/// Internal dispatch — regular libopus encoder for 1/2 channels, or
123/// multistream encoder for 3..=8 channels. The two paths converge at
124/// the [`AudioEncoder`] trait surface.
125enum OpusInner {
126    Regular(OpusEncoderInner),
127    /// Owned `OpusMSEncoder*` from `opus_multistream_encoder_create`.
128    /// Freed via `opus_multistream_encoder_destroy` in `Drop`.
129    Multistream(MultistreamEncoder),
130}
131
132/// RAII wrapper over a raw `OpusMSEncoder*`. The underlying libopus
133/// state is allocated on the libopus heap; we destroy it via the FFI
134/// destroy call when the wrapper drops. The pointer is non-null after
135/// successful construction (`MultistreamEncoder::new` enforces this).
136struct MultistreamEncoder {
137    state: *mut ffi::OpusMSEncoder,
138}
139
140// SAFETY: libopus's multistream encoder state has no implicit thread
141// affinity — like the regular Encoder we expose it via `&mut self`
142// methods only, so external aliasing is impossible. The Send bound
143// matches what audiopus's high-level Encoder claims.
144unsafe impl Send for MultistreamEncoder {}
145
146impl MultistreamEncoder {
147    /// Allocate + initialize a multistream encoder for the given
148    /// channel-mapping family-1 layout. `mapping.len()` must equal
149    /// `channels`. Internally calls `opus_multistream_encoder_create`.
150    fn new(
151        sample_rate: u32,
152        channels: u8,
153        streams: u8,
154        coupled_streams: u8,
155        mapping: &[u8],
156        application: Application,
157    ) -> Result<Self, AudioError> {
158        if mapping.len() != channels as usize {
159            return Err(AudioError::Encode(format!(
160                "multistream mapping length {} != channels {channels}",
161                mapping.len()
162            )));
163        }
164        // libopus invariant: streams + coupled_streams <= channels and
165        // coupled_streams <= streams. Re-check here so a hand-crafted
166        // call from inside this module (e.g. via a future API change)
167        // can't slip past the public surround_mapping_family_1 helper.
168        if coupled_streams > streams {
169            return Err(AudioError::Encode(format!(
170                "coupled_streams ({coupled_streams}) > streams ({streams})"
171            )));
172        }
173        if (streams as usize) + (coupled_streams as usize) > channels as usize {
174            return Err(AudioError::Encode(format!(
175                "streams ({streams}) + coupled_streams ({coupled_streams}) > channels ({channels})"
176            )));
177        }
178
179        let mut err: c_int = 0;
180        // SAFETY: `mapping.as_ptr()` is valid for `channels` bytes
181        // (asserted above); libopus reads `channels` bytes from it
182        // synchronously. `&mut err` is a valid out-pointer for c_int.
183        // The returned pointer is checked for null on the libopus
184        // contract: non-null implies err == OPUS_OK.
185        let state = unsafe {
186            ffi::opus_multistream_encoder_create(
187                sample_rate as i32,
188                channels as c_int,
189                streams as c_int,
190                coupled_streams as c_int,
191                mapping.as_ptr(),
192                application as c_int,
193                &mut err,
194            )
195        };
196        if state.is_null() || err != ffi::OPUS_OK {
197            return Err(AudioError::Encode(format!(
198                "opus_multistream_encoder_create failed: code={err}"
199            )));
200        }
201        Ok(Self { state })
202    }
203
204    /// Set per-encoder VBR. CTL request OPUS_SET_VBR_REQUEST takes an
205    /// i32 (0 = CBR, 1 = VBR).
206    fn set_vbr(&mut self, vbr: bool) -> Result<(), AudioError> {
207        let val: c_int = if vbr { 1 } else { 0 };
208        // SAFETY: `self.state` is a valid OpusMSEncoder*; the variadic
209        // CTL ABI expects the request id followed by exactly one i32
210        // argument for OPUS_SET_VBR (per libopus opus_defines.h).
211        let r = unsafe {
212            ffi::opus_multistream_encoder_ctl(self.state, ffi::OPUS_SET_VBR_REQUEST, val)
213        };
214        if r != ffi::OPUS_OK {
215            return Err(AudioError::Encode(format!(
216                "opus_multistream_encoder_ctl(SET_VBR) failed: {r}"
217            )));
218        }
219        Ok(())
220    }
221
222    /// Set the *aggregate* bitrate across all streams. libopus
223    /// distributes this internally proportional to each stream's
224    /// channel count (mono streams ~half a stereo stream's allocation).
225    fn set_bitrate(&mut self, bps: i32) -> Result<(), AudioError> {
226        // SAFETY: same as set_vbr; OPUS_SET_BITRATE takes an i32.
227        let r = unsafe {
228            ffi::opus_multistream_encoder_ctl(self.state, ffi::OPUS_SET_BITRATE_REQUEST, bps)
229        };
230        if r != ffi::OPUS_OK {
231            return Err(AudioError::Encode(format!(
232                "opus_multistream_encoder_ctl(SET_BITRATE) failed: {r}"
233            )));
234        }
235        Ok(())
236    }
237
238    /// Query the encoder's lookahead in samples at the configured
239    /// sample rate (always 48 kHz for our usage). Returned as the
240    /// `dOps.PreSkip` field per RFC 7845 §4.2.
241    ///
242    /// Returned as `u32` to match the audiopus high-level Encoder
243    /// signature (libopus actually surfaces a non-negative i32 — the
244    /// CTL never returns negative lookahead values).
245    fn lookahead(&self) -> Result<u32, AudioError> {
246        let mut out: c_int = 0;
247        // SAFETY: OPUS_GET_LOOKAHEAD takes a `*mut int` out parameter.
248        let r = unsafe {
249            ffi::opus_multistream_encoder_ctl(
250                self.state,
251                ffi::OPUS_GET_LOOKAHEAD_REQUEST,
252                &mut out as *mut c_int,
253            )
254        };
255        if r != ffi::OPUS_OK {
256            return Err(AudioError::Encode(format!(
257                "opus_multistream_encoder_ctl(GET_LOOKAHEAD) failed: {r}"
258            )));
259        }
260        if out < 0 {
261            return Err(AudioError::Encode(format!(
262                "opus_multistream_encoder_ctl(GET_LOOKAHEAD) returned negative: {out}"
263            )));
264        }
265        Ok(out as u32)
266    }
267
268    /// Encode one 20-ms multichannel frame from interleaved f32 input.
269    /// `pcm.len()` must equal `frame_size * channels`. Returns the
270    /// encoded packet length in bytes (always positive on success).
271    fn encode_float(
272        &mut self,
273        pcm: &[f32],
274        frame_size: usize,
275        out: &mut [u8],
276    ) -> Result<usize, AudioError> {
277        let max = out.len().min(i32::MAX as usize) as i32;
278        // SAFETY: pcm.as_ptr() valid for frame_size*channels f32s
279        // (caller guarantees via slice length); out.as_mut_ptr() valid
280        // for `max` bytes; libopus reads/writes only within those
281        // bounds.
282        let n = unsafe {
283            ffi::opus_multistream_encode_float(
284                self.state,
285                pcm.as_ptr(),
286                frame_size as c_int,
287                out.as_mut_ptr(),
288                max,
289            )
290        };
291        if n < 0 {
292            return Err(AudioError::Encode(format!(
293                "opus_multistream_encode_float failed: code={n}"
294            )));
295        }
296        Ok(n as usize)
297    }
298}
299
300impl Drop for MultistreamEncoder {
301    fn drop(&mut self) {
302        if !self.state.is_null() {
303            // SAFETY: state was allocated by opus_multistream_encoder_create
304            // and is destroyed exactly once (Drop runs once).
305            unsafe { ffi::opus_multistream_encoder_destroy(self.state) };
306            self.state = ptr::null_mut();
307        }
308    }
309}
310
311pub struct OpusEncoder {
312    inner: OpusInner,
313    /// Source sample rate the caller will feed.
314    in_rate: u32,
315    /// Channel count (1..=8).
316    channels: u8,
317    /// Resampler when in_rate != 48 kHz, else None.
318    resampler: Option<AudioResampler>,
319    /// Carry of resampled (or directly-fed) samples that didn't fill a
320    /// full Opus frame yet. Interleaved planar f32.
321    sample_carry: Vec<f32>,
322    /// pre_skip in 48 kHz samples — captured at construction.
323    pre_skip_48k: u16,
324    /// dOps body bytes per RFC 7845 §4.5 — built once at construction
325    /// from in_rate + channels + pre_skip + (when multichannel)
326    /// streams + coupled_streams + channel mapping.
327    extra_data: Vec<u8>,
328    /// Running PTS in microseconds. Set on first encode call.
329    next_pts_us: Option<i64>,
330    /// Microseconds per Opus frame at the configured frame size.
331    frame_duration_us: i64,
332    /// Reusable encode output buffer to avoid per-frame allocation.
333    encode_out: Vec<u8>,
334}
335
336impl OpusEncoder {
337    pub fn new(config: AudioEncoderConfig) -> Result<Self, AudioError> {
338        if config.codec != AudioCodec::Opus {
339            return Err(AudioError::Encode(format!(
340                "OpusEncoder constructed with codec {:?}",
341                config.codec
342            )));
343        }
344        if config.channels == 0 {
345            return Err(AudioError::Unsupported(
346                "Opus channel count must be >= 1".to_string(),
347            ));
348        }
349        if config.channels > 8 {
350            return Err(AudioError::Unsupported(format!(
351                "Opus supports up to 8 channels (channel-mapping family 1, RFC 7845 §5.1.1.2); \
352                 got {} channels",
353                config.channels
354            )));
355        }
356        if config.sample_rate == 0 {
357            return Err(AudioError::Encode("input sample_rate is 0".to_string()));
358        }
359
360        let channels = config.channels;
361
362        // Construct the inner encoder + capture multistream metadata
363        // (streams / coupled_streams / mapping) when on the multistream
364        // path. Both paths converge into a single OpusInner.
365        let (inner, ms_meta, max_packet_bytes) = if channels <= 2 {
366            // Regular API path — Squad-24's original code.
367            let opus_channels = match channels {
368                1 => OpusChannels::Mono,
369                2 => OpusChannels::Stereo,
370                _ => unreachable!("channel-count guarded above"),
371            };
372            let mut enc =
373                OpusEncoderInner::new(SampleRate::Hz48000, opus_channels, Application::Audio)
374                    .map_err(|e| AudioError::Encode(format!("opus encoder create: {e}")))?;
375            let bitrate_bps = if config.bitrate == 0 {
376                if channels == 1 {
377                    DEFAULT_BITRATE_MONO
378                } else {
379                    DEFAULT_BITRATE_STEREO
380                }
381            } else {
382                config.bitrate
383            };
384            enc.set_bitrate(Bitrate::BitsPerSecond(bitrate_bps as i32))
385                .map_err(|e| AudioError::Encode(format!("opus set_bitrate: {e}")))?;
386            // VBR is the audiopus default but we set it explicitly for
387            // documentation; CBR is reserved for streaming use cases not
388            // relevant to file output.
389            enc.set_vbr(true)
390                .map_err(|e| AudioError::Encode(format!("opus set_vbr: {e}")))?;
391            (OpusInner::Regular(enc), None, OPUS_MAX_PACKET_BYTES)
392        } else {
393            // Multistream path: build the family-1 layout, allocate the
394            // libopus multistream encoder via FFI.
395            let (streams, coupled, mapping) = surround_mapping_family_1(channels)?;
396            let mut ms = MultistreamEncoder::new(
397                OPUS_INTERNAL_RATE,
398                channels,
399                streams,
400                coupled,
401                mapping,
402                Application::Audio,
403            )?;
404            // Default aggregate bitrate scales with streams: 96 kbps per
405            // coupled (stereo) + 64 kbps per uncoupled (mono). For 5.1
406            // (4 streams, 2 coupled) this is 2*96 + 2*64 = 320 kbps,
407            // which is the Opus reference default for surround.
408            let bitrate_bps = if config.bitrate == 0 {
409                let coupled_u = coupled as u32;
410                let mono_u = streams as u32 - coupled_u;
411                coupled_u * DEFAULT_BITRATE_STEREO + mono_u * DEFAULT_BITRATE_MONO
412            } else {
413                config.bitrate
414            };
415            ms.set_bitrate(bitrate_bps as i32)?;
416            ms.set_vbr(true)?;
417            (
418                OpusInner::Multistream(ms),
419                Some((streams, coupled, mapping)),
420                OPUS_MAX_MS_PACKET_BYTES,
421            )
422        };
423
424        // Read the lookahead in 48 kHz ticks regardless of which inner
425        // path we took. Both regular + multistream report lookahead in
426        // samples-of-the-configured-rate per libopus convention; we
427        // configure both at 48 kHz so no scaling is needed.
428        let pre_skip_48k_u32 = match &inner {
429            OpusInner::Regular(enc) => enc
430                .lookahead()
431                .map_err(|e| AudioError::Encode(format!("opus lookahead: {e}")))?,
432            OpusInner::Multistream(ms) => ms.lookahead()?,
433        };
434        let pre_skip_48k: u16 = pre_skip_48k_u32.try_into().unwrap_or(u16::MAX);
435
436        // Resampler if needed.
437        let resampler = if config.sample_rate == OPUS_INTERNAL_RATE {
438            None
439        } else {
440            // chunk_size: process 20 ms worth of input at a time so the
441            // resampler output naturally aligns with Opus's 20 ms frame
442            // size. 20 ms at 44.1 kHz = 882 samples, at 22.05 kHz = 441,
443            // etc. We round to the nearest integer.
444            let chunk = ((config.sample_rate as usize) * 20) / 1000;
445            let chunk = chunk.max(1);
446            Some(AudioResampler::new(
447                config.sample_rate,
448                OPUS_INTERNAL_RATE,
449                channels,
450                chunk,
451            )?)
452        };
453
454        let extra_data = build_dops(channels, pre_skip_48k, config.sample_rate, ms_meta);
455
456        let frame_duration_us =
457            (OPUS_FRAME_SAMPLES_48K as i64 * 1_000_000) / OPUS_INTERNAL_RATE as i64;
458
459        Ok(Self {
460            inner,
461            in_rate: config.sample_rate,
462            channels,
463            resampler,
464            sample_carry: Vec::with_capacity(OPUS_FRAME_SAMPLES_48K * channels as usize * 4),
465            pre_skip_48k,
466            extra_data,
467            next_pts_us: None,
468            frame_duration_us,
469            encode_out: vec![0u8; max_packet_bytes],
470        })
471    }
472
473    /// Drain as many full 20-ms Opus frames as possible from
474    /// `sample_carry`. Each successful encode advances `next_pts_us`
475    /// by `frame_duration_us`.
476    fn drain_packets(&mut self) -> Result<Vec<EncodedAudioPacket>, AudioError> {
477        let mut out = Vec::new();
478        let chans = self.channels as usize;
479        let frame_interleaved_len = OPUS_FRAME_SAMPLES_48K * chans;
480        while self.sample_carry.len() >= frame_interleaved_len {
481            // Encode the front-most frame.
482            let frame_slice = &self.sample_carry[..frame_interleaved_len];
483            let n = match &mut self.inner {
484                OpusInner::Regular(enc) => enc
485                    .encode_float(frame_slice, &mut self.encode_out)
486                    .map_err(|e| AudioError::Encode(format!("opus encode_float: {e}")))?,
487                OpusInner::Multistream(ms) => {
488                    ms.encode_float(frame_slice, OPUS_FRAME_SAMPLES_48K, &mut self.encode_out)?
489                }
490            };
491            // n=0 would be a discontinuous-transmission "no packet"
492            // signal — we don't enable DTX so it shouldn't fire, but
493            // defensively skip if it does.
494            if n > 0 {
495                let pts = self.next_pts_us.unwrap_or(0);
496                self.next_pts_us = Some(pts + self.frame_duration_us);
497                out.push(EncodedAudioPacket {
498                    data: self.encode_out[..n].to_vec(),
499                    pts,
500                    duration: OPUS_FRAME_SAMPLES_48K as i64, // 48 kHz ticks
501                });
502            }
503            self.sample_carry.drain(..frame_interleaved_len);
504        }
505        Ok(out)
506    }
507}
508
509impl AudioEncoder for OpusEncoder {
510    fn encode(&mut self, frame: &AudioFrame) -> Result<Vec<EncodedAudioPacket>, AudioError> {
511        // Channel-count gate. Multichannel (3..=8) is now supported via
512        // the Multistream API path (Squad-28); >8 stays Unsupported.
513        if frame.channels == 0 || frame.channels > 8 {
514            return Err(AudioError::Unsupported(format!(
515                "Opus AudioFrame channel count must be 1..=8; got {}",
516                frame.channels
517            )));
518        }
519        if frame.channels != self.channels {
520            return Err(AudioError::Encode(format!(
521                "channel count mismatch: encoder configured for {}, frame has {}",
522                self.channels, frame.channels
523            )));
524        }
525        if frame.sample_rate != self.in_rate {
526            return Err(AudioError::Encode(format!(
527                "sample rate mismatch: encoder configured for {}, frame has {}",
528                self.in_rate, frame.sample_rate
529            )));
530        }
531
532        if self.next_pts_us.is_none() {
533            self.next_pts_us = Some(frame.pts);
534        }
535
536        // Push samples into carry, possibly via resampler.
537        if let Some(r) = self.resampler.as_mut() {
538            r.process(frame, &mut self.sample_carry)?;
539        } else {
540            self.sample_carry.extend_from_slice(&frame.samples);
541        }
542
543        self.drain_packets()
544    }
545
546    fn flush(&mut self) -> Result<Vec<EncodedAudioPacket>, AudioError> {
547        if let Some(r) = self.resampler.as_mut() {
548            r.flush(&mut self.sample_carry)?;
549        }
550        // Pad the final partial frame with silence so libopus can emit
551        // a final packet (mux side will use pre_skip + the file's
552        // total sample count to know where playable audio ends).
553        let chans = self.channels as usize;
554        let frame_interleaved_len = OPUS_FRAME_SAMPLES_48K * chans;
555        if !self.sample_carry.is_empty() && self.sample_carry.len() < frame_interleaved_len {
556            self.sample_carry.resize(frame_interleaved_len, 0.0);
557        }
558        self.drain_packets()
559    }
560
561    fn pre_skip(&self) -> u16 {
562        self.pre_skip_48k
563    }
564
565    fn extra_data(&self) -> Vec<u8> {
566        self.extra_data.clone()
567    }
568}
569
570/// Build the `dOps` body (RFC 7845 §4.5).
571///
572/// Two layouts:
573/// - Family 0 (mono / stereo): 11 bytes total.
574/// - Family 1 (surround 1..=8 channels, RFC 7845 §5.1.1.2): 11 + 2 + N
575///   bytes total, where N is the channel count. The 11-byte preamble is
576///   identical to family 0; the trailer adds StreamCount, CoupledCount,
577///   and ChannelMapping (N bytes).
578///
579/// All multi-byte fields are LITTLE-endian per the RFC. The mux side
580/// (container/src/mux.rs::build_dops) reads these LE bytes back and
581/// translates to BE for the on-wire dOps box.
582///
583/// ```text
584/// Version:               u8  = 0
585/// OutputChannelCount:    u8  (1..=8)
586/// PreSkip:               u16 LE
587/// InputSampleRate:       u32 LE  (original/source rate)
588/// OutputGain:            i16 LE  (0 = no gain change)
589/// ChannelMappingFamily:  u8  (0 for 1-2 channels, 1 for 3-8)
590/// // Family 1 only:
591/// StreamCount:           u8
592/// CoupledCount:          u8
593/// ChannelMapping[N]:     u8 each (output-channel → encoder-stream index)
594/// ```
595fn build_dops(
596    channels: u8,
597    pre_skip_48k: u16,
598    input_sample_rate: u32,
599    ms_meta: Option<(u8, u8, &[u8])>,
600) -> Vec<u8> {
601    // Choose family based on whether multistream metadata is present.
602    let (family, total_len) = match ms_meta {
603        None => (0u8, 11usize),
604        Some(_) => (1u8, 11 + 2 + channels as usize),
605    };
606
607    let mut v = Vec::with_capacity(total_len);
608    v.push(0u8); // Version
609    v.push(channels);
610    v.extend_from_slice(&pre_skip_48k.to_le_bytes());
611    v.extend_from_slice(&input_sample_rate.to_le_bytes());
612    v.extend_from_slice(&0i16.to_le_bytes()); // OutputGain
613    v.push(family);
614
615    if let Some((streams, coupled, mapping)) = ms_meta {
616        v.push(streams);
617        v.push(coupled);
618        // ChannelMapping: one byte per *output* channel; value is the
619        // encoder-stream index for that output channel.
620        v.extend_from_slice(mapping);
621        debug_assert_eq!(mapping.len(), channels as usize);
622    }
623    debug_assert_eq!(v.len(), total_len);
624    v
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use audiopus::Channels as OpusChannels;
631    use audiopus::SampleRate;
632    use audiopus::coder::Decoder as OpusDecoderInner;
633
634    fn config_stereo_48k() -> AudioEncoderConfig {
635        AudioEncoderConfig {
636            codec: AudioCodec::Opus,
637            sample_rate: 48_000,
638            channels: 2,
639            bitrate: 96_000,
640        }
641    }
642
643    fn config_mono_48k() -> AudioEncoderConfig {
644        AudioEncoderConfig {
645            codec: AudioCodec::Opus,
646            sample_rate: 48_000,
647            channels: 1,
648            bitrate: 64_000,
649        }
650    }
651
652    fn config_multi_48k(channels: u8) -> AudioEncoderConfig {
653        AudioEncoderConfig {
654            codec: AudioCodec::Opus,
655            sample_rate: 48_000,
656            channels,
657            bitrate: 0, // exercise the per-stream default-bitrate path
658        }
659    }
660
661    #[test]
662    fn opus_encoder_constructs_for_mono_48k_with_1_channel_dops() {
663        let enc = OpusEncoder::new(config_mono_48k()).expect("constructs");
664        assert_eq!(enc.channels, 1);
665        assert!(enc.resampler.is_none());
666        // dOps[1] = OutputChannelCount = 1 for mono
667        assert_eq!(enc.extra_data[1], 1);
668    }
669
670    #[test]
671    fn opus_encoder_uses_default_bitrate_when_caller_passes_zero() {
672        let mut cfg = config_stereo_48k();
673        cfg.bitrate = 0;
674        let _enc = OpusEncoder::new(cfg).expect("constructs with bitrate=0");
675        // Default bitrate path doesn't expose the value via a public
676        // method on audiopus's Encoder without GenericCtl, but the
677        // constructor would fail if it tried to set an invalid
678        // bitrate. The fact that we got here means the default
679        // (DEFAULT_BITRATE_STEREO=96k) was applied successfully.
680    }
681
682    fn config_stereo_44100() -> AudioEncoderConfig {
683        AudioEncoderConfig {
684            codec: AudioCodec::Opus,
685            sample_rate: 44_100,
686            channels: 2,
687            bitrate: 96_000,
688        }
689    }
690
691    fn make_silence(channels: u8, frames: usize, sample_rate: u32) -> AudioFrame {
692        AudioFrame {
693            samples: vec![0.0f32; frames * channels as usize],
694            sample_rate,
695            channels,
696            pts: 0,
697        }
698    }
699
700    fn make_sine_1k(channels: u8, frames: usize, sample_rate: u32, amp: f32) -> AudioFrame {
701        let mut samples = Vec::with_capacity(frames * channels as usize);
702        let two_pi = std::f32::consts::PI * 2.0;
703        let freq = 1000.0f32;
704        for i in 0..frames {
705            let t = i as f32 / sample_rate as f32;
706            let v = (two_pi * freq * t).sin() * amp;
707            for _ in 0..channels {
708                samples.push(v);
709            }
710        }
711        AudioFrame {
712            samples,
713            sample_rate,
714            channels,
715            pts: 0,
716        }
717    }
718
719    #[test]
720    fn opus_encoder_constructs_for_stereo_48k() {
721        let enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
722        assert_eq!(enc.channels, 2);
723        assert_eq!(enc.in_rate, 48000);
724        assert!(enc.resampler.is_none(), "no resampler at native rate");
725        assert_eq!(enc.extra_data.len(), 11, "dOps body must be 11 bytes");
726        // dOps[0] = Version = 0
727        assert_eq!(enc.extra_data[0], 0);
728        // dOps[1] = OutputChannelCount
729        assert_eq!(enc.extra_data[1], 2);
730        // dOps[10] = ChannelMappingFamily = 0
731        assert_eq!(enc.extra_data[10], 0);
732    }
733
734    #[test]
735    fn opus_encoder_resamples_44100_to_48k_internally() {
736        let enc = OpusEncoder::new(config_stereo_44100()).expect("constructs");
737        assert!(enc.resampler.is_some(), "resampler engaged at 44.1k input");
738        let r = enc.resampler.as_ref().unwrap();
739        assert_eq!(r.in_rate(), 44100);
740        assert_eq!(r.out_rate(), 48000);
741    }
742
743    #[test]
744    fn opus_encoder_rejects_zero_channels() {
745        let mut bad = config_stereo_48k();
746        bad.channels = 0;
747        assert!(matches!(
748            OpusEncoder::new(bad),
749            Err(AudioError::Unsupported(_))
750        ));
751    }
752
753    #[test]
754    fn opus_encoder_rejects_nine_channels() {
755        // 9 channels (and above) has no defined channel-mapping family-1
756        // layout in RFC 7845 §5.1.1.2, so we Unsupported it.
757        let mut bad9 = config_stereo_48k();
758        bad9.channels = 9;
759        assert!(matches!(
760            OpusEncoder::new(bad9),
761            Err(AudioError::Unsupported(_))
762        ));
763    }
764
765    #[test]
766    fn opus_encoder_rejects_nine_channel_frame_at_runtime() {
767        let mut enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
768        let bad_frame = AudioFrame {
769            samples: vec![0.0; 960 * 9],
770            sample_rate: 48000,
771            channels: 9,
772            pts: 0,
773        };
774        let r = enc.encode(&bad_frame);
775        assert!(
776            matches!(r, Err(AudioError::Unsupported(_))),
777            "9-channel frame should be Unsupported, got {:?}",
778            r
779        );
780    }
781
782    #[test]
783    fn opus_pre_skip_in_48khz_ticks_is_nonzero() {
784        let enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
785        // libopus typically reports lookahead in the 312..=400 sample
786        // range at 48 kHz. We just sanity-check it's nonzero.
787        assert!(
788            enc.pre_skip() > 0,
789            "Opus encoder lookahead should be positive (libopus convention)"
790        );
791        assert!(
792            enc.pre_skip() < 2000,
793            "lookahead is bounded — typically <600 samples at 48 kHz"
794        );
795    }
796
797    #[test]
798    fn opus_dops_carries_correct_pre_skip_and_input_sample_rate_le() {
799        let enc = OpusEncoder::new(config_stereo_44100()).expect("constructs");
800        let d = enc.extra_data();
801        // PreSkip at offset 2 (LE u16)
802        let ps = u16::from_le_bytes([d[2], d[3]]);
803        assert_eq!(ps, enc.pre_skip(), "dOps PreSkip matches encoder lookahead");
804        // InputSampleRate at offset 4 (LE u32)
805        let isr = u32::from_le_bytes([d[4], d[5], d[6], d[7]]);
806        assert_eq!(
807            isr, 44100,
808            "dOps InputSampleRate is the source rate, not 48k"
809        );
810        // OutputGain at offset 8 (LE i16, default 0)
811        let og = i16::from_le_bytes([d[8], d[9]]);
812        assert_eq!(og, 0);
813    }
814
815    #[test]
816    fn opus_encode_20ms_silence_produces_one_packet() {
817        let mut enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
818        // 20 ms at 48 kHz = 960 frames per channel
819        let frame = make_silence(2, 960, 48_000);
820        let pkts = enc.encode(&frame).expect("encode");
821        assert_eq!(pkts.len(), 1, "exactly one Opus packet for one 20ms frame");
822        let pkt = &pkts[0];
823        assert!(!pkt.data.is_empty(), "packet should have bytes");
824        // Silence at 96 kbps stereo: Opus DTX is OFF so we still get
825        // a regular packet. Should be small (a few dozen bytes).
826        assert!(
827            pkt.data.len() < 200,
828            "silence packet at 96 kbps should be small, got {} bytes",
829            pkt.data.len()
830        );
831        assert_eq!(pkt.duration, 960, "20ms = 960 ticks at 48k");
832    }
833
834    #[test]
835    fn opus_encode_one_second_of_sine_produces_packets_with_reasonable_bitrate() {
836        let mut enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
837        // Feed 1 second of 1 kHz sine in 20 ms slices so we have round
838        // numbers. 48000 / 960 = 50 frames per second.
839        let mut total_bytes = 0usize;
840        let mut total_packets = 0usize;
841        for i in 0..50 {
842            let mut frame = make_sine_1k(2, 960, 48_000, 0.3);
843            // Stagger the per-slice phase by adjusting pts; the
844            // generator above uses i=0..960 so phase resets each
845            // slice — for this test we don't care about phase
846            // continuity across slices, only about bitrate aggregate.
847            frame.pts = i * 20_000;
848            let pkts = enc.encode(&frame).expect("encode");
849            for p in &pkts {
850                total_bytes += p.data.len();
851                total_packets += 1;
852            }
853        }
854        let pkts_flush = enc.flush().expect("flush");
855        for p in &pkts_flush {
856            total_bytes += p.data.len();
857            total_packets += 1;
858        }
859        // Expect ~50 packets for 1 s of audio (one per 20 ms)
860        assert!(
861            total_packets >= 49 && total_packets <= 51,
862            "expected ~50 packets for 1 s of audio, got {total_packets}"
863        );
864        // 1 second at 96 kbps = 96000 bits = 12000 bytes target.
865        // VBR encoder will be within ±30% of this on a sine wave.
866        let observed_bps = (total_bytes as u64 * 8) as i64;
867        assert!(
868            observed_bps > 30_000 && observed_bps < 200_000,
869            "1s of 1kHz sine at 96 kbps should yield 30-200 kbps actual, got {observed_bps} bps ({total_bytes} bytes)"
870        );
871    }
872
873    #[test]
874    fn opus_pts_steps_by_20ms_per_packet() {
875        let mut enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
876        let frame_a = make_silence(2, 960, 48_000);
877        let mut frame_b = make_silence(2, 960, 48_000);
878        frame_b.pts = 20_000;
879        let pkts_a = enc.encode(&frame_a).expect("a");
880        let pkts_b = enc.encode(&frame_b).expect("b");
881        assert_eq!(pkts_a.len(), 1);
882        assert_eq!(pkts_b.len(), 1);
883        let dt = pkts_b[0].pts - pkts_a[0].pts;
884        // 20 ms in microseconds = 20_000
885        assert_eq!(
886            dt, 20_000,
887            "PTS should step by 20_000 us per Opus packet (20 ms frame)"
888        );
889    }
890
891    /// Round-trip: encode a sine wave then decode through libopus and
892    /// compare against the input. Opus is lossy (especially silence
893    /// padding at the front for pre_skip) so we measure RMS error
894    /// over the steady-state portion only.
895    #[test]
896    fn opus_round_trip_sine_wave_quality_is_acceptable() {
897        let mut enc = OpusEncoder::new(config_stereo_48k()).expect("constructs");
898        let frames_per_chunk = 960;
899        let n_chunks = 25; // ~500 ms
900        let total_frames = frames_per_chunk * n_chunks;
901
902        // Continuous-phase 1 kHz sine across all chunks.
903        let mut all_samples = Vec::with_capacity(total_frames * 2);
904        let two_pi = std::f32::consts::PI * 2.0;
905        let freq = 1000.0f32;
906        for i in 0..total_frames {
907            let t = i as f32 / 48_000.0;
908            let v = (two_pi * freq * t).sin() * 0.5;
909            all_samples.push(v);
910            all_samples.push(v);
911        }
912
913        // Encode chunk by chunk.
914        let mut packets = Vec::new();
915        for c in 0..n_chunks {
916            let chunk_samples =
917                all_samples[c * frames_per_chunk * 2..(c + 1) * frames_per_chunk * 2].to_vec();
918            let frame = AudioFrame {
919                samples: chunk_samples,
920                sample_rate: 48_000,
921                channels: 2,
922                pts: (c as i64) * 20_000,
923            };
924            packets.extend(enc.encode(&frame).expect("encode"));
925        }
926        packets.extend(enc.flush().expect("flush"));
927        assert!(!packets.is_empty(), "encode must produce packets");
928
929        // Decode with audiopus.
930        let mut dec =
931            OpusDecoderInner::new(SampleRate::Hz48000, OpusChannels::Stereo).expect("dec");
932        let mut decoded = Vec::with_capacity(total_frames * 2);
933        let mut tmp = vec![0.0f32; frames_per_chunk * 2];
934        for p in &packets {
935            let pkt = audiopus::packet::Packet::try_from(p.data.as_slice()).expect("pkt");
936            let sig = audiopus::MutSignals::try_from(tmp.as_mut_slice()).expect("sig");
937            let n = dec
938                .decode_float(Some(pkt), sig, false)
939                .expect("decode_float");
940            decoded.extend_from_slice(&tmp[..n * 2]);
941        }
942        assert!(
943            decoded.len() >= (total_frames - 100) * 2,
944            "decoded length {} should approximate input length {}",
945            decoded.len(),
946            total_frames * 2
947        );
948
949        // Compare the steady-state portion (skip pre_skip + a couple
950        // hundred extra samples for filter warm-up) to the original.
951        // Opus decoder output is delayed by `pre_skip` 48k samples
952        // relative to the original input.
953        let pre_skip = enc.pre_skip() as usize;
954        let cmp_start = pre_skip + 480; // skip first 10 ms more
955        let cmp_end = (decoded.len() / 2).min(total_frames - 100);
956        if cmp_end <= cmp_start {
957            panic!(
958                "round trip too short: cmp_start={cmp_start}, cmp_end={cmp_end}, decoded len/2={}",
959                decoded.len() / 2
960            );
961        }
962
963        let mut sum_sq_err = 0.0f64;
964        let mut sum_sq_sig = 0.0f64;
965        let mut n = 0usize;
966        for i in cmp_start..cmp_end {
967            // Opus decoder output at sample i corresponds to input at
968            // sample (i - pre_skip). The decoded buffer already starts
969            // at output sample 0, and pre_skip samples of it are the
970            // encoder's lookahead "padding" — input sample 0 of the
971            // user's stream lives at decoder output sample pre_skip.
972            let in_idx = i - pre_skip;
973            let l_in = all_samples[in_idx * 2];
974            let r_in = all_samples[in_idx * 2 + 1];
975            let l_out = decoded[i * 2];
976            let r_out = decoded[i * 2 + 1];
977            sum_sq_err += ((l_in - l_out) as f64).powi(2);
978            sum_sq_err += ((r_in - r_out) as f64).powi(2);
979            sum_sq_sig += (l_in as f64).powi(2);
980            sum_sq_sig += (r_in as f64).powi(2);
981            n += 2;
982        }
983        let rms_err = (sum_sq_err / n as f64).sqrt();
984        let rms_sig = (sum_sq_sig / n as f64).sqrt();
985        let snr_db = 20.0 * (rms_sig / rms_err.max(1e-12)).log10();
986        // A sine wave round-tripped through Opus at 96 kbps stereo
987        // should land >15 dB SNR easily — Opus is transparent on
988        // simple tones at this bitrate. We use a conservative bound
989        // because exact SNR depends on libopus version.
990        assert!(
991            snr_db > 15.0,
992            "round-trip SNR {snr_db:.2} dB too low — Opus quality regression?"
993        );
994        // Print so the deliverables report can capture the actual
995        // number from `cargo test -- --nocapture`.
996        println!("opus_round_trip SNR = {snr_db:.2} dB, rms_err = {rms_err:.4}");
997    }
998
999    #[test]
1000    fn dops_layout_matches_rfc_7845_for_mono_and_stereo() {
1001        let d_mono = build_dops(1, 312, 48_000, None);
1002        assert_eq!(d_mono.len(), 11);
1003        assert_eq!(d_mono[0], 0); // Version
1004        assert_eq!(d_mono[1], 1); // ChannelCount
1005        assert_eq!(u16::from_le_bytes([d_mono[2], d_mono[3]]), 312); // PreSkip
1006        assert_eq!(
1007            u32::from_le_bytes([d_mono[4], d_mono[5], d_mono[6], d_mono[7]]),
1008            48000
1009        ); // InputSampleRate
1010        assert_eq!(i16::from_le_bytes([d_mono[8], d_mono[9]]), 0); // OutputGain
1011        assert_eq!(d_mono[10], 0); // Family
1012
1013        let d_stereo = build_dops(2, 400, 44_100, None);
1014        assert_eq!(d_stereo.len(), 11);
1015        assert_eq!(d_stereo[1], 2);
1016        assert_eq!(u16::from_le_bytes([d_stereo[2], d_stereo[3]]), 400);
1017        assert_eq!(
1018            u32::from_le_bytes([d_stereo[4], d_stereo[5], d_stereo[6], d_stereo[7]]),
1019            44100
1020        );
1021    }
1022
1023    // -------- Squad-28 multistream tests below --------
1024
1025    /// Standard surround layouts per RFC 7845 §5.1.1.2. Each pair
1026    /// `(channels, (streams, coupled, mapping))` matches the spec
1027    /// table exactly.
1028    #[test]
1029    fn surround_mapping_family_1_matches_rfc_7845_5_1_1_2() {
1030        // 3.0 — L, R, C → coupled[L,R] + stream[C]
1031        assert_eq!(
1032            surround_mapping_family_1(3).unwrap(),
1033            (2, 1, &[0, 2, 1][..])
1034        );
1035        // quad — FL, FR, BL, BR → coupled[FL,FR] + coupled[BL,BR]
1036        assert_eq!(
1037            surround_mapping_family_1(4).unwrap(),
1038            (2, 2, &[0, 1, 2, 3][..])
1039        );
1040        // 5.0 — FL, FR, C, BL, BR
1041        assert_eq!(
1042            surround_mapping_family_1(5).unwrap(),
1043            (3, 2, &[0, 4, 1, 2, 3][..])
1044        );
1045        // 5.1 — FL, FR, C, LFE, BL, BR
1046        assert_eq!(
1047            surround_mapping_family_1(6).unwrap(),
1048            (4, 2, &[0, 4, 1, 2, 3, 5][..])
1049        );
1050        // 6.1 — FL, FR, C, LFE, BC, SL, SR
1051        // (streams=4, coupled=3; libopus authoritative — see
1052        // `vorbis_mappings[]` in opus_multistream_encoder.c:60).
1053        assert_eq!(
1054            surround_mapping_family_1(7).unwrap(),
1055            (4, 3, &[0, 4, 1, 2, 3, 5, 6][..])
1056        );
1057        // 7.1 — FL, FR, C, LFE, BL, BR, SL, SR
1058        assert_eq!(
1059            surround_mapping_family_1(8).unwrap(),
1060            (5, 3, &[0, 6, 1, 2, 3, 4, 5, 7][..])
1061        );
1062        // Out-of-range
1063        assert!(surround_mapping_family_1(0).is_err());
1064        assert!(surround_mapping_family_1(1).is_err()); // family-1 is 3..=8
1065        assert!(surround_mapping_family_1(2).is_err());
1066        assert!(surround_mapping_family_1(9).is_err());
1067    }
1068
1069    #[test]
1070    fn opus_encoder_constructs_for_3_0_through_7_1_with_family_1_dops() {
1071        // For each surround channel count, the encoder should construct
1072        // and the dOps body should be 11 + 2 + N bytes with family=1
1073        // and the spec-mandated streams/coupled/mapping appended.
1074        for &ch in &[3u8, 4, 5, 6, 7, 8] {
1075            let enc = OpusEncoder::new(config_multi_48k(ch))
1076                .unwrap_or_else(|e| panic!("constructs for {ch}ch: {e:?}"));
1077            assert_eq!(enc.channels, ch);
1078            assert!(enc.resampler.is_none(), "no resampler at native rate");
1079
1080            let d = enc.extra_data();
1081            let expected_len = 11 + 2 + ch as usize;
1082            assert_eq!(
1083                d.len(),
1084                expected_len,
1085                "dOps body for {ch}ch should be {expected_len} bytes (11 preamble + 2 stream/coupled + N mapping); got {}",
1086                d.len()
1087            );
1088            assert_eq!(
1089                d[0], 0,
1090                "Version=0 (dOps box version, not Opus stream version)"
1091            );
1092            assert_eq!(d[1], ch, "OutputChannelCount");
1093            assert_eq!(d[10], 1, "ChannelMappingFamily=1 for surround");
1094
1095            let (exp_streams, exp_coupled, exp_mapping) = surround_mapping_family_1(ch).unwrap();
1096            assert_eq!(d[11], exp_streams, "StreamCount for {ch}ch");
1097            assert_eq!(d[12], exp_coupled, "CoupledCount for {ch}ch");
1098            assert_eq!(
1099                &d[13..13 + ch as usize],
1100                exp_mapping,
1101                "ChannelMapping for {ch}ch"
1102            );
1103        }
1104    }
1105
1106    /// dOps body for a 5.1 encoder, hex-dumped. Captured in the
1107    /// deliverables report for cross-tool verification.
1108    #[test]
1109    fn opus_encoder_dops_5_1_hex_layout() {
1110        let enc = OpusEncoder::new(config_multi_48k(6)).expect("5.1 constructs");
1111        let d = enc.extra_data();
1112        assert_eq!(d.len(), 19, "5.1 dOps body = 11 + 2 + 6 = 19 bytes");
1113        let hex: String = d.iter().map(|b| format!("{b:02x} ")).collect();
1114        println!(
1115            "5.1 dOps body hex (LE-encoded, 19 bytes): {}",
1116            hex.trim_end()
1117        );
1118        // Layout cross-check:
1119        assert_eq!(d[0], 0); // Version
1120        assert_eq!(d[1], 6); // OutputChannelCount
1121        // PreSkip varies by libopus build; check it's non-zero
1122        let ps = u16::from_le_bytes([d[2], d[3]]);
1123        assert!(ps > 0 && ps < 2000);
1124        assert_eq!(
1125            u32::from_le_bytes([d[4], d[5], d[6], d[7]]),
1126            48_000,
1127            "InputSampleRate=48000"
1128        );
1129        assert_eq!(i16::from_le_bytes([d[8], d[9]]), 0); // OutputGain
1130        assert_eq!(d[10], 1); // Family=1
1131        assert_eq!(d[11], 4); // StreamCount=4 (5.1)
1132        assert_eq!(d[12], 2); // CoupledCount=2 (5.1)
1133        assert_eq!(&d[13..19], &[0u8, 4, 1, 2, 3, 5][..]); // ChannelMapping
1134    }
1135
1136    #[test]
1137    fn opus_5_1_encode_20ms_silence_produces_one_packet() {
1138        let mut enc = OpusEncoder::new(config_multi_48k(6)).expect("5.1 constructs");
1139        // 20 ms at 48 kHz, 6 channels
1140        let frame = make_silence(6, 960, 48_000);
1141        let pkts = enc.encode(&frame).expect("encode 5.1 silence");
1142        assert_eq!(pkts.len(), 1, "exactly one Opus packet for one 20ms frame");
1143        let pkt = &pkts[0];
1144        assert!(!pkt.data.is_empty());
1145        // Multistream silence packet is larger than the mono case
1146        // because there's >=4 internal streams emitting their own
1147        // silence frame — but should still be small in absolute terms.
1148        assert!(
1149            pkt.data.len() < 600,
1150            "5.1 silence packet should still be under ~600 bytes, got {} bytes",
1151            pkt.data.len()
1152        );
1153        assert_eq!(pkt.duration, 960);
1154    }
1155
1156    /// Round-trip 5.1 sine through libopus multistream encode + decode,
1157    /// computing per-channel SNR. Each channel carries a different
1158    /// frequency so cross-channel bleed would show up as low SNR.
1159    #[test]
1160    fn opus_5_1_round_trip_per_channel_snr_is_acceptable() {
1161        // Per-channel sine frequencies (Hz). Distinct so a coupled
1162        // stream that mixed channels would show degraded SNR.
1163        // 5.1 channel order: FL, FR, C, LFE, BL, BR
1164        let freqs = [440.0f32, 523.25, 659.25, 80.0, 880.0, 987.77];
1165        let chans: u8 = 6;
1166        let frames_per_chunk = 960;
1167        let n_chunks = 30; // ~600 ms
1168        let total_frames = frames_per_chunk * n_chunks;
1169        let amp = 0.4f32;
1170
1171        // Build the multichannel input. Continuous phase across chunks.
1172        let mut all = vec![0.0f32; total_frames * chans as usize];
1173        let two_pi = std::f32::consts::PI * 2.0;
1174        for i in 0..total_frames {
1175            let t = i as f32 / 48_000.0;
1176            for ch in 0..chans as usize {
1177                all[i * chans as usize + ch] = (two_pi * freqs[ch] * t).sin() * amp;
1178            }
1179        }
1180
1181        // Encode.
1182        let mut enc = OpusEncoder::new(config_multi_48k(chans)).expect("encoder");
1183        let mut packets = Vec::new();
1184        for c in 0..n_chunks {
1185            let frame = AudioFrame {
1186                samples: all[c * frames_per_chunk * chans as usize
1187                    ..(c + 1) * frames_per_chunk * chans as usize]
1188                    .to_vec(),
1189                sample_rate: 48_000,
1190                channels: chans,
1191                pts: (c as i64) * 20_000,
1192            };
1193            packets.extend(enc.encode(&frame).expect("encode"));
1194        }
1195        packets.extend(enc.flush().expect("flush"));
1196        assert!(!packets.is_empty(), "must produce packets");
1197
1198        // Decode via the multistream API directly through audiopus_sys.
1199        let (streams, coupled, mapping) = surround_mapping_family_1(chans).unwrap();
1200        let mut err: c_int = 0;
1201        let dec_state = unsafe {
1202            ffi::opus_multistream_decoder_create(
1203                48_000,
1204                chans as c_int,
1205                streams as c_int,
1206                coupled as c_int,
1207                mapping.as_ptr(),
1208                &mut err,
1209            )
1210        };
1211        assert!(
1212            !dec_state.is_null() && err == ffi::OPUS_OK,
1213            "MS decoder create"
1214        );
1215
1216        let mut decoded = Vec::with_capacity(total_frames * chans as usize);
1217        let mut tmp = vec![0.0f32; frames_per_chunk * chans as usize];
1218        for p in &packets {
1219            let n = unsafe {
1220                ffi::opus_multistream_decode_float(
1221                    dec_state,
1222                    p.data.as_ptr(),
1223                    p.data.len() as i32,
1224                    tmp.as_mut_ptr(),
1225                    frames_per_chunk as c_int,
1226                    0,
1227                )
1228            };
1229            assert!(n > 0, "MS decode_float returned {n}");
1230            decoded.extend_from_slice(&tmp[..(n as usize) * chans as usize]);
1231        }
1232        unsafe { ffi::opus_multistream_decoder_destroy(dec_state) };
1233
1234        // Per-channel SNR over the steady-state portion. Skip pre_skip
1235        // + 480 samples of filter warm-up at the front, plus a small
1236        // tail margin.
1237        let pre_skip = enc.pre_skip() as usize;
1238        let cmp_start = pre_skip + 480;
1239        let cmp_end = (decoded.len() / chans as usize).min(total_frames - 200);
1240        assert!(cmp_end > cmp_start, "round trip too short");
1241
1242        let mut snrs = Vec::with_capacity(chans as usize);
1243        for ch in 0..chans as usize {
1244            let mut sum_sq_err = 0.0f64;
1245            let mut sum_sq_sig = 0.0f64;
1246            for i in cmp_start..cmp_end {
1247                let in_idx = i - pre_skip;
1248                let s_in = all[in_idx * chans as usize + ch];
1249                let s_out = decoded[i * chans as usize + ch];
1250                sum_sq_err += ((s_in - s_out) as f64).powi(2);
1251                sum_sq_sig += (s_in as f64).powi(2);
1252            }
1253            let n = (cmp_end - cmp_start) as f64;
1254            let rms_err = (sum_sq_err / n).sqrt();
1255            let rms_sig = (sum_sq_sig / n).sqrt();
1256            let snr_db = 20.0 * (rms_sig / rms_err.max(1e-12)).log10();
1257            snrs.push(snr_db);
1258        }
1259
1260        println!("5.1 per-channel SNR (dB):");
1261        for (i, snr) in snrs.iter().enumerate() {
1262            let label = ["FL", "FR", "C", "LFE", "BL", "BR"][i];
1263            println!("  ch{i} ({label}): {snr:.2} dB");
1264        }
1265
1266        // Each channel should land >= 5 dB SNR on a steady tone.
1267        // Multistream Opus at default per-stream bitrate (~320 kbps
1268        // total) is transparent on a simple sine, but the LFE channel
1269        // is allocated less bitrate by libopus and lower-frequency
1270        // tones have proportionally larger error per sample, so we use
1271        // a conservative bound.
1272        for (i, snr) in snrs.iter().enumerate() {
1273            assert!(
1274                *snr > 5.0,
1275                "ch{i} SNR {snr:.2} dB too low — multistream quality regression?"
1276            );
1277        }
1278    }
1279
1280    #[test]
1281    fn dops_layout_for_5_1_matches_family_1_spec() {
1282        let (streams, coupled, mapping) = surround_mapping_family_1(6).unwrap();
1283        let d = build_dops(6, 312, 48_000, Some((streams, coupled, mapping)));
1284        assert_eq!(d.len(), 11 + 2 + 6, "5.1 dOps = 19 bytes");
1285        assert_eq!(d[0], 0); // Version
1286        assert_eq!(d[1], 6); // OutputChannelCount
1287        assert_eq!(u16::from_le_bytes([d[2], d[3]]), 312); // PreSkip
1288        assert_eq!(u32::from_le_bytes([d[4], d[5], d[6], d[7]]), 48_000); // InputSampleRate
1289        assert_eq!(i16::from_le_bytes([d[8], d[9]]), 0); // OutputGain
1290        assert_eq!(d[10], 1); // Family=1
1291        assert_eq!(d[11], 4); // StreamCount=4 for 5.1
1292        assert_eq!(d[12], 2); // CoupledCount=2 for 5.1
1293        assert_eq!(&d[13..19], &[0u8, 4, 1, 2, 3, 5][..]);
1294    }
1295
1296    /// 5.1 encoder at a non-48k input rate must engage the resampler
1297    /// for its 6 channels — gates the resampler-channel-cap lift.
1298    #[test]
1299    fn opus_5_1_resamples_44100_to_48k() {
1300        let mut cfg = config_multi_48k(6);
1301        cfg.sample_rate = 44_100;
1302        let enc = OpusEncoder::new(cfg).expect("5.1 @ 44.1k constructs");
1303        assert!(enc.resampler.is_some(), "resampler engaged for 6ch @ 44.1k");
1304        let r = enc.resampler.as_ref().unwrap();
1305        assert_eq!(r.in_rate(), 44_100);
1306        assert_eq!(r.out_rate(), 48_000);
1307        assert_eq!(r.channels(), 6);
1308    }
1309}