Skip to main content

moq_audio/decode/
decoder.rs

1//! Audio decoder front end.
2//!
3//! Mirror of [`encode::Encoder`](crate::encode::Encoder): dispatches over the
4//! catalog codec and produces interleaved `f32` PCM.
5
6use unsafe_libopus::{
7	OPUS_OK, OPUS_RESET_STATE, OPUS_SET_GAIN_REQUEST, OpusDecoder, opus_decode_float, opus_decoder_create,
8	opus_decoder_ctl_impl, opus_decoder_destroy, varargs,
9};
10
11#[cfg(feature = "aac")]
12use symphonia_core::codecs::audio::AudioDecoder;
13
14use super::Decoded;
15#[cfg(feature = "aac")]
16use crate::aac;
17use crate::opus;
18use crate::pcm;
19use crate::{Activity, Error, Layout};
20
21/// Opus packets cap at 120 ms (RFC 6716 ยง2.1.4).
22const MAX_FRAME_MS: usize = 120;
23
24/// Decoder backend selection.
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum Kind {
28	/// Pick the available backend automatically.
29	#[default]
30	Auto,
31	/// Require the built-in software backend.
32	Software,
33	/// Require a backend by its stable lowercase name.
34	Named(String),
35}
36
37/// Low-level decoder configuration.
38#[derive(Clone, Debug, Default)]
39#[non_exhaustive]
40pub struct Config {
41	/// Backend selection policy.
42	pub kind: Kind,
43}
44
45impl Config {
46	/// Select the available backend automatically.
47	pub fn new() -> Self {
48		Self::default()
49	}
50}
51
52/// Decodes codec packets into interleaved `f32` PCM.
53///
54/// The bring-your-own-payload layer under [`Consumer`](super::Consumer): use it
55/// when the packets don't come from a plain track subscription.
56pub struct Decoder {
57	backend: Backend,
58	sample_rate: u32,
59	layout: Layout,
60	delay: usize,
61}
62
63enum Backend {
64	Opus(Opus),
65	Pcm {
66		bytes_per_frame: usize,
67	},
68	#[cfg(feature = "aac")]
69	Aac(Box<Aac>),
70}
71
72struct Opus {
73	inner: *mut OpusDecoder,
74	pre_skip_remaining: usize,
75	max_frame_size: usize,
76	in_dtx: bool,
77}
78
79// SAFETY: see Encoder.
80unsafe impl Send for Opus {}
81
82/// Boxed in [`Backend`]: the symphonia decoder carries its own filterbank state,
83/// which is far larger than the other backends' handles.
84#[cfg(feature = "aac")]
85struct Aac {
86	inner: symphonia_codec_aac::AacDecoder,
87}
88
89impl Decoder {
90	/// Build a decoder from a catalog [`AudioConfig`](hang::catalog::AudioConfig).
91	///
92	/// Opus decodes at 48 kHz with the pre-skip and gain its OpusHead
93	/// `description` declares, refusing a malformed head or a channel mapping
94	/// other than mono/stereo; without a description it takes the catalog's
95	/// channel count and applies neither. PCM uses the catalog fields directly
96	/// and requires an absent `description`.
97	pub fn new(catalog: &hang::catalog::AudioConfig, config: &Config) -> Result<Self, Error> {
98		let name = match &catalog.codec {
99			hang::catalog::AudioCodec::Opus => "opus",
100			hang::catalog::AudioCodec::Pcm => "pcm",
101			#[cfg(feature = "aac")]
102			hang::catalog::AudioCodec::AAC(_) => "aac",
103			codec => return Err(Error::Unsupported(format!("unsupported audio codec: {codec}"))),
104		};
105		match &config.kind {
106			Kind::Auto | Kind::Software => {}
107			Kind::Named(requested) if requested == name => {}
108			Kind::Named(requested) => {
109				return Err(Error::Unsupported(format!(
110					"audio decoder backend {requested:?} is unavailable for {name}"
111				)));
112			}
113		}
114		match &catalog.codec {
115			hang::catalog::AudioCodec::Opus => Self::new_opus(catalog),
116			hang::catalog::AudioCodec::Pcm => Self::new_pcm(catalog),
117			#[cfg(feature = "aac")]
118			hang::catalog::AudioCodec::AAC(aac) => Self::new_aac(catalog, aac.profile),
119			codec => Err(Error::Unsupported(format!("unsupported audio codec: {codec}"))),
120		}
121	}
122
123	fn new_opus(catalog: &hang::catalog::AudioConfig) -> Result<Self, Error> {
124		let head = opus::head(catalog)?;
125		let channels = opus::validate_channels(head.channel_count)?;
126
127		let mut err = 0i32;
128		// SAFETY: out-pointer is valid; inner is checked for null below.
129		let inner = unsafe { opus_decoder_create(opus::DECODE_RATE as i32, channels, &mut err) };
130		if err != OPUS_OK || inner.is_null() {
131			return Err(opus::error(err, "opus_decoder_create"));
132		}
133		// Owned before the gain ctl so a failure there still destroys the decoder.
134		let decoder = Opus {
135			inner,
136			pre_skip_remaining: head.pre_skip as usize,
137			max_frame_size: (opus::DECODE_RATE as usize * MAX_FRAME_MS) / 1000,
138			in_dtx: false,
139		};
140
141		// Same Q7.8 dB units as OpusHead, and it survives OPUS_RESET_STATE.
142		// SAFETY: `inner` owns a live decoder and OPUS_SET_GAIN takes one i32.
143		let rc =
144			unsafe { opus_decoder_ctl_impl(decoder.inner, OPUS_SET_GAIN_REQUEST, varargs![head.output_gain as i32]) };
145		if rc != OPUS_OK {
146			return Err(opus::error(rc, "OPUS_SET_GAIN"));
147		}
148
149		Ok(Self {
150			delay: decoder.pre_skip_remaining,
151			backend: Backend::Opus(decoder),
152			sample_rate: opus::DECODE_RATE,
153			layout: Layout::from_channels(head.channel_count)?,
154		})
155	}
156
157	/// AAC-LC only, which is what every gateway that feeds this crate publishes.
158	///
159	/// HE-AAC is rejected however its config spells it: leading with SBR or PS
160	/// (mp4a.40.5 / .29), or leading with LC and declaring SBR in a sync extension
161	/// after the core. Symphonia decodes no SBR either way, so the alternative is
162	/// half-rate audio that sounds like a fault rather than an unsupported codec.
163	/// A stream that signals SBR only in band is indistinguishable from LC in the
164	/// config, and does decode as the core.
165	#[cfg(feature = "aac")]
166	fn new_aac(catalog: &hang::catalog::AudioConfig, profile: u8) -> Result<Self, Error> {
167		use symphonia_core::codecs::audio::well_known::CODEC_ID_AAC;
168		use symphonia_core::codecs::audio::{AudioCodecParameters, AudioDecoderOptions};
169
170		let description = aac::description(catalog, profile)?;
171
172		let mut params = AudioCodecParameters::new();
173		params
174			.for_codec(CODEC_ID_AAC)
175			.with_extra_data(description.to_vec().into_boxed_slice());
176
177		let inner = symphonia_codec_aac::AacDecoder::try_new(&params, &AudioDecoderOptions::default())
178			.map_err(|err| Error::Unsupported(format!("aac decoder: {err}")))?;
179
180		// Resolved by the decoder from the config, so this is what it will emit
181		// even when the catalog's own fields say otherwise.
182		let params = inner.codec_params();
183		let sample_rate = params
184			.sample_rate
185			.ok_or_else(|| Error::Unsupported("aac config declares no sample rate".into()))?;
186		let channel_count = params
187			.channels
188			.as_ref()
189			.map(|channels| channels.count())
190			.ok_or_else(|| Error::Unsupported("aac config declares no channels".into()))?;
191
192		Ok(Self {
193			backend: Backend::Aac(Box::new(Aac { inner })),
194			sample_rate,
195			layout: Layout::from_channels(channel_count as u32)?,
196			delay: 0,
197		})
198	}
199
200	fn new_pcm(catalog: &hang::catalog::AudioConfig) -> Result<Self, Error> {
201		if catalog.sample_rate == 0 {
202			return Err(Error::Unsupported("pcm sample rate must be greater than zero".into()));
203		}
204		if catalog.channel_count == 0 {
205			return Err(Error::Unsupported("pcm channel count must be greater than zero".into()));
206		}
207		if catalog.description.is_some() {
208			return Err(Error::Unsupported("pcm catalog description must be absent".into()));
209		}
210		let bitrate = pcm::bitrate(catalog.sample_rate, catalog.channel_count)?;
211		if catalog.bitrate.is_some_and(|declared| declared != bitrate) {
212			return Err(Error::Unsupported(format!(
213				"pcm catalog bitrate must be {bitrate} bits per second"
214			)));
215		}
216		let bytes_per_frame = pcm::frame_bytes(1, catalog.channel_count)?;
217
218		Ok(Self {
219			backend: Backend::Pcm { bytes_per_frame },
220			sample_rate: catalog.sample_rate,
221			layout: Layout::from_channels(catalog.channel_count)?,
222			delay: 0,
223		})
224	}
225
226	/// The rate the codec decodes at, read from the catalog.
227	pub fn sample_rate(&self) -> u32 {
228		self.sample_rate
229	}
230
231	/// The PCM layout decoded from the catalog.
232	pub fn layout(&self) -> Layout {
233		self.layout
234	}
235
236	/// Reset codec history and reapply startup delay for a new discontinuous epoch.
237	pub fn reset(&mut self) -> Result<(), Error> {
238		self.reset_prediction()?;
239		self.reapply_delay();
240		Ok(())
241	}
242
243	/// Reapply catalog startup delay for a new playhead epoch without resetting codec prediction.
244	pub(super) fn reapply_delay(&mut self) {
245		if let Backend::Opus(opus) = &mut self.backend {
246			opus.pre_skip_remaining = self.delay;
247		}
248	}
249
250	/// Reset codec prediction after packet loss without reapplying stream startup delay.
251	pub(super) fn reset_prediction(&mut self) -> Result<(), Error> {
252		match &mut self.backend {
253			Backend::Opus(opus) => {
254				// SAFETY: `inner` owns a live decoder and OPUS_RESET_STATE takes no arguments.
255				let rc = unsafe { opus_decoder_ctl_impl(opus.inner, OPUS_RESET_STATE, varargs![]) };
256				if rc != OPUS_OK {
257					return Err(crate::opus::error(rc, "OPUS_RESET_STATE"));
258				}
259				opus.in_dtx = false;
260			}
261			Backend::Pcm { .. } => {}
262			#[cfg(feature = "aac")]
263			Backend::Aac(aac) => aac.inner.reset(),
264		}
265		Ok(())
266	}
267
268	/// How much startup delay is still to be trimmed, in native-rate frames.
269	///
270	/// Trimmed samples are media the packet covered even though nothing came out of
271	/// it, so a caller tracking where a packet ends has to add back whatever this
272	/// dropped across the call.
273	pub(super) fn delay_remaining(&self) -> usize {
274		match &self.backend {
275			Backend::Opus(opus) => opus.pre_skip_remaining,
276			Backend::Pcm { .. } => 0,
277			#[cfg(feature = "aac")]
278			Backend::Aac(_) => 0,
279		}
280	}
281
282	/// Decode one packet into interleaved `f32` PCM and report its codec activity.
283	///
284	/// Empty Opus packets invoke packet-loss concealment. Loss during DTX remains
285	/// classified as DTX, while loss during active audio remains active.
286	pub fn decode(&mut self, packet: &[u8]) -> Result<Decoded, Error> {
287		match &mut self.backend {
288			Backend::Opus(opus) => {
289				let channels = self.layout.channels() as usize;
290				let mut out = vec![0.0f32; opus.max_frame_size * channels];
291				// SAFETY: `inner` owns a live OpusDecoder; packet/out slices are
292				// bounded by the lengths we pass.
293				let samples = unsafe {
294					opus_decode_float(
295						&mut *opus.inner,
296						packet.as_ptr(),
297						packet.len() as i32,
298						out.as_mut_ptr(),
299						opus.max_frame_size as i32,
300						0,
301					)
302				};
303				if samples < 0 {
304					return Err(crate::opus::decode_error(samples));
305				}
306				out.truncate(samples as usize * channels);
307				let trim_frames = opus.pre_skip_remaining.min(samples as usize);
308				if trim_frames > 0 {
309					let trim_samples = trim_frames * channels;
310					out.copy_within(trim_samples.., 0);
311					out.truncate(out.len() - trim_samples);
312					opus.pre_skip_remaining -= trim_frames;
313				}
314				let activity = crate::opus::activity(packet, opus.in_dtx);
315				opus.in_dtx = activity.is_dtx();
316				Ok(Decoded { samples: out, activity })
317			}
318			Backend::Pcm { bytes_per_frame } => {
319				if packet.is_empty() || !packet.len().is_multiple_of(*bytes_per_frame) {
320					return Err(Error::Misaligned {
321						got: packet.len(),
322						expected: packet.len().max(1).next_multiple_of(*bytes_per_frame),
323					});
324				}
325
326				let out = packet
327					.as_chunks::<{ pcm::BYTES_PER_SAMPLE }>()
328					.0
329					.iter()
330					.map(|sample| f32::from_le_bytes([sample[0], sample[1], sample[2], sample[3]]))
331					.collect();
332				Ok(Decoded {
333					samples: out,
334					activity: Activity::Active,
335				})
336			}
337			#[cfg(feature = "aac")]
338			Backend::Aac(aac) => {
339				// The packet is a raw AAC frame, not ADTS, so there is nothing to
340				// timestamp it with here: the container carries the timestamp and the
341				// decoder only reads the payload.
342				let packet = symphonia_core::packet::PacketRef::new(
343					0,
344					symphonia_core::units::Timestamp::ZERO,
345					symphonia_core::units::Duration::ZERO,
346					packet,
347				);
348
349				let decoded = aac
350					.inner
351					.decode_ref(&packet)
352					.map_err(|err| Error::Decode(format!("aac: {err}")))?;
353
354				let mut out = Vec::new();
355				decoded.copy_to_vec_interleaved(&mut out);
356				Ok(Decoded {
357					samples: out,
358					activity: Activity::Active,
359				})
360			}
361		}
362	}
363}
364
365impl Drop for Opus {
366	fn drop(&mut self) {
367		// SAFETY: `inner` is a live OpusDecoder that nothing else aliases.
368		unsafe { opus_decoder_destroy(self.inner) };
369	}
370}
371
372#[cfg(test)]
373pub(crate) mod tests {
374	use super::*;
375
376	/// Three consecutive AAC-LC frames of a 440 Hz full-scale sine, mono at
377	/// 44.1 kHz, and the AudioSpecificConfig that opens them. Generated with:
378	///
379	/// ```text
380	/// ffmpeg -f lavfi -i "sine=frequency=440:sample_rate=44100:duration=0.2" -af volume=8 -ac 1 -c:a aac -b:a 32k -f adts sine.aac
381	/// ```
382	///
383	/// then stripping the ADTS header off each frame, since the wire carries raw
384	/// AAC. These are frames 2 to 4, past the encoder's priming. lavfi's sine is
385	/// an eighth of full scale, which is what the volume filter is undoing.
386	#[cfg(feature = "aac")]
387	const AAC_DESCRIPTION: &[u8] = b"\x12\x08";
388
389	#[cfg(feature = "aac")]
390	const AAC_FRAMES: [&[u8]; 3] = [
391		b"\x01\x52\xf2\x8b\x1a\xd7\x8e\x7b\xfd\xa7\xef\xe7\xe3\x55\xd3\x4d\x2f\x55\x2e\x47\x1c\x92\x49\x11\x20\x77\x3f\xbe\x74\xdd\x99\xb3\x7b\xfb\x90\xc9\xf0\x61\x9f\xdc\x0c\x9f\x06\x19\xfd\xe1\x1f\x1f\x00\x67\xf7\x03\x87\xc0\x19\xfd\xc0\xc9\xf0\x07",
392		b"\x01\x1e\x32\x89\xe2\x9d\x6b\x33\xe7\xff\xe2\xfe\xbf\xfa\xff\xe7\x2f\x8b\xd5\xd5\xe7\x5f\x3f\x59\xeb\xf1\xcb\xba\xa5\x5e\x52\x4a\xbd\x8d\x74\x50\x8c\x08\xa8\xa0\xd4\x51\x40\xa1\x86\x5d\x06\xb4\x6c\x32\xe6\x25\x9a\x66\x75\xcd\xf9\xbf\x6f\x83\xb7\x53\x80",
393		b"\x01\x1e\x32\x8a\x22\x7d\x40\x87\x48\xdb\xdf\xff\xf9\x4f\xff\x87\xde\xef\x8b\xeb\x1e\x77\x5d\xfc\x67\x8f\x8c\x77\x8a\xd6\x29\x96\x1f\x29\xe7\x39\xd4\x53\xcf\x3c\xf3\xce\x79\xd4\x27\x9c\xf5\x65\x2a\x9b\xe9\x80\xb7\xba\xa9\xf9\x58\xc7\x3c\x58\x27\x8a\x60\xa1\x57",
394	];
395
396	#[cfg(feature = "aac")]
397	fn aac_catalog() -> hang::catalog::AudioConfig {
398		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AAC { profile: 2 }, 44_100, 1);
399		catalog.description = Some(bytes::Bytes::from_static(AAC_DESCRIPTION));
400		catalog
401	}
402
403	#[cfg(feature = "aac")]
404	#[test]
405	fn aac_decodes_a_sine() {
406		let mut decoder = Decoder::new(&aac_catalog(), &Config::default()).unwrap();
407		assert_eq!(decoder.sample_rate(), 44_100);
408		assert_eq!(decoder.layout(), Layout::Mono);
409
410		let decoded: Vec<Vec<f32>> = AAC_FRAMES
411			.iter()
412			.map(|frame| decoder.decode(frame).unwrap().samples)
413			.collect();
414
415		// AAC-LC frames are 1024 samples each, whatever the packet size.
416		for pcm in &decoded {
417			assert_eq!(pcm.len(), 1024);
418		}
419
420		// The first frame is missing the previous frame's overlap, so measure the
421		// last one. ffmpeg decodes this same frame to 0.744 RMS, near the 0.707 of
422		// an ideal full-scale sine.
423		let last = decoded.last().unwrap();
424		let rms = (last.iter().map(|s| s * s).sum::<f32>() / last.len() as f32).sqrt();
425		assert!((0.65..0.8).contains(&rms), "expected a full-scale sine, got {rms} RMS");
426	}
427
428	#[cfg(feature = "aac")]
429	#[test]
430	fn aac_reports_a_truncated_packet_as_decode() {
431		let mut decoder = Decoder::new(&aac_catalog(), &Config::default()).unwrap();
432
433		let truncated = &AAC_FRAMES[0][..16];
434		assert!(matches!(decoder.decode(truncated), Err(Error::Decode(_))));
435	}
436
437	#[cfg(feature = "aac")]
438	#[test]
439	fn aac_synthesizes_a_missing_description() {
440		// An MSF catalog carries the shape in its own fields instead.
441		let mut catalog = aac_catalog();
442		catalog.description = None;
443
444		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
445		assert_eq!(decoder.sample_rate(), 44_100);
446		assert_eq!(decoder.decode(AAC_FRAMES[0]).unwrap().samples.len(), 1024);
447	}
448
449	/// A packet libopus rejects is that packet's problem, not the
450	/// configuration's. The distinction is what lets a consumer drop the frame and
451	/// keep the subscription instead of ending the stream over one bad packet.
452	#[test]
453	fn opus_reports_a_rejected_packet_as_decode() {
454		let head = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap();
455		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
456		catalog.description = Some(head);
457
458		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
459
460		// Not a valid TOC byte sequence: libopus reports OPUS_INVALID_PACKET.
461		assert!(matches!(decoder.decode(&[0xFF; 3]), Err(Error::Decode(_))));
462	}
463
464	/// Real Opus: 20 ms packets of a continuous 440 Hz sine, mono, from libopus
465	/// with its 312-sample lookahead.
466	pub(crate) fn opus_packets(count: usize) -> Vec<bytes::Bytes> {
467		let mut encoder = crate::encode::Encoder::new(&crate::encode::Settings::new(48_000, Layout::Mono)).unwrap();
468		let frames = encoder.frame_size();
469		(0..count)
470			.map(|packet| {
471				let pcm: Vec<f32> = (packet * frames..(packet + 1) * frames)
472					.map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / 48_000.0).sin() * 0.5)
473					.collect();
474				encoder.encode(&pcm).unwrap().payload
475			})
476			.collect()
477	}
478
479	/// A catalog shaped like an import's: the OpusHead input rate as the catalog rate.
480	pub(crate) fn opus_catalog(head: moq_mux::codec::opus::Config) -> hang::catalog::AudioConfig {
481		let mut catalog =
482			hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, head.sample_rate, head.channel_count);
483		catalog.description = Some(head.encode().unwrap());
484		catalog
485	}
486
487	fn rms(samples: &[f32]) -> f32 {
488		(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
489	}
490
491	/// The OpusHead input rate is metadata: 44.1 kHz and unknown (0) are valid
492	/// heads, and no input rate changes the 48 kHz clock the packets decode on.
493	#[test]
494	fn opus_decodes_at_48k_whatever_the_input_rate() {
495		let packets = opus_packets(2);
496		for input_rate in [0, 8_000, 24_000, 44_100, 48_000, 96_000] {
497			let head = moq_mux::codec::opus::Config::new(input_rate, 1).with_pre_skip(312);
498			let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap();
499			assert_eq!(decoder.sample_rate(), 48_000, "input rate {input_rate}");
500
501			// The pre-skip is 48 kHz samples, trimmed once.
502			assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960 - 312);
503			assert_eq!(decoder.decode(&packets[1]).unwrap().samples.len(), 960);
504		}
505	}
506
507	#[test]
508	fn opus_applies_the_declared_gain() {
509		let packets = opus_packets(5);
510		let decode = |output_gain: i16| {
511			let mut head = moq_mux::codec::opus::Config::new(48_000, 1);
512			head.output_gain = output_gain;
513			let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap();
514			let mut last = Vec::new();
515			for packet in &packets {
516				last = decoder.decode(packet).unwrap().samples;
517			}
518			// A reset after loss keeps the gain.
519			decoder.reset().unwrap();
520			let reset = decoder.decode(&packets[4]).unwrap().samples;
521			(rms(&last), rms(&reset))
522		};
523
524		// -6.02 dB in Q7.8 halves the amplitude.
525		let (plain, plain_reset) = decode(0);
526		let (quiet, quiet_reset) = decode(-1541);
527		assert!((quiet / plain - 0.5).abs() < 0.001, "gain ratio {}", quiet / plain);
528		assert!(
529			(quiet_reset / plain_reset - 0.5).abs() < 0.001,
530			"gain ratio after reset {}",
531			quiet_reset / plain_reset
532		);
533	}
534
535	/// A present description is the stream's configuration, so a broken one is
536	/// refused rather than replaced by the catalog's fields.
537	#[test]
538	fn opus_refuses_a_malformed_description() {
539		let valid = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec();
540		let mut version = valid.clone();
541		version[8] = 16;
542		let mut channels = valid.clone();
543		channels[9] = 3;
544		let mut signature = valid.clone();
545		signature[0] = b'X';
546		// Family 1 promising a table that is not there.
547		let mut table = valid.clone();
548		table[18] = 1;
549
550		for (name, description) in [
551			("truncated", valid[..18].to_vec()),
552			("empty", Vec::new()),
553			("signature", signature),
554			("version", version),
555			("channels", channels),
556			("table", table),
557		] {
558			let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
559			catalog.description = Some(description.into());
560			assert!(
561				matches!(Decoder::new(&catalog, &Config::default()), Err(Error::Unsupported(_))),
562				"{name}"
563			);
564		}
565	}
566
567	/// Mapping families other than 0 need the multistream decoder; a stereo
568	/// family 1 head is refused rather than decoded as family 0.
569	#[test]
570	fn opus_refuses_unsupported_channel_mappings() {
571		let valid = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec();
572		for family in [1, 255] {
573			let mut description = valid.clone();
574			description[18] = family;
575			// One coupled stream, left then right.
576			description.extend_from_slice(&[1, 1, 0, 1]);
577
578			let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
579			catalog.description = Some(description.into());
580			assert!(
581				matches!(Decoder::new(&catalog, &Config::default()), Err(Error::Unsupported(_))),
582				"family {family}"
583			);
584		}
585	}
586
587	/// Without a description the catalog shapes the stream, which has no pre-skip.
588	#[test]
589	fn opus_decodes_without_a_description() {
590		let packets = opus_packets(1);
591		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 24_000, 1);
592		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
593		assert_eq!(decoder.sample_rate(), 48_000);
594		assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960);
595
596		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 6);
597		assert!(matches!(
598			Decoder::new(&catalog, &Config::default()),
599			Err(Error::Unsupported(_))
600		));
601	}
602
603	#[test]
604	fn pcm_rejects_incomplete_channel_frame() {
605		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
606		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
607
608		assert!(matches!(
609			decoder.decode(&[]),
610			Err(Error::Misaligned { got: 0, expected: 8 })
611		));
612		assert!(matches!(
613			decoder.decode(&[0; 4]),
614			Err(Error::Misaligned { got: 4, expected: 8 })
615		));
616	}
617
618	#[test]
619	fn decoder_rejects_unknown_codec() {
620		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Unknown("future".into()), 48_000, 2);
621
622		assert!(matches!(
623			Decoder::new(&catalog, &Config::default()),
624			Err(Error::Unsupported(_))
625		));
626	}
627
628	#[test]
629	fn pcm_rejects_incorrect_catalog_bitrate() {
630		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
631		catalog.bitrate = Some(1);
632
633		assert!(matches!(
634			Decoder::new(&catalog, &Config::default()),
635			Err(Error::Unsupported(_))
636		));
637	}
638
639	#[test]
640	fn refuses_unavailable_backend() {
641		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
642		let config = Config {
643			kind: Kind::Named("missing".into()),
644		};
645		assert!(matches!(Decoder::new(&catalog, &config), Err(Error::Unsupported(_))));
646	}
647}