Skip to main content

moq_audio/decode/
decoder.rs

1//! Audio decoder front end.
2//!
3//! Mirror of [`encode::Encoder`](crate::encode::Encoder): opens a
4//! [`Backend`](super::backend::Backend) for the catalog codec and trims its
5//! startup delay, producing interleaved `f32` PCM.
6
7use super::Decoded;
8use super::backend::{self, Backend};
9use crate::{Error, Layout};
10
11/// Decoder backend selection.
12#[derive(Clone, Debug, Default, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Kind {
15	/// Prefer a platform decoder, falling back to software.
16	#[default]
17	Auto,
18	/// Require a software backend.
19	Software,
20	/// Require the track to be this codec, by its lowercase name: `"opus"`,
21	/// `"pcm"`, or `"aac"`. Any other codec is refused. The library still picks
22	/// the backend, platform first.
23	Named(String),
24}
25
26/// Low-level decoder configuration.
27#[derive(Clone, Debug, Default)]
28#[non_exhaustive]
29pub struct Config {
30	/// Backend selection policy.
31	pub kind: Kind,
32}
33
34impl Config {
35	/// Select the available backend automatically.
36	pub fn new() -> Self {
37		Self::default()
38	}
39}
40
41/// Decodes codec packets into interleaved `f32` PCM.
42///
43/// The bring-your-own-payload layer under [`Consumer`](super::Consumer): use it
44/// when the packets don't come from a plain track subscription.
45pub struct Decoder {
46	backend: Box<dyn Backend>,
47	/// Startup delay in native-rate frames, and how much of it is left to trim.
48	delay: usize,
49	delay_remaining: usize,
50}
51
52impl Decoder {
53	/// Build a decoder from a catalog [`AudioConfig`](hang::catalog::AudioConfig).
54	///
55	/// Opus decodes at 48 kHz with the pre-skip and gain its OpusHead
56	/// `description` declares, refusing a malformed head; without a description
57	/// it takes the catalog's channel count and applies neither. PCM uses the
58	/// catalog fields directly and requires an absent `description`. AAC reads
59	/// its AudioSpecificConfig, synthesizing one from the catalog when absent.
60	pub fn new(catalog: &hang::catalog::AudioConfig, config: &Config) -> Result<Self, Error> {
61		let backend = backend::open(catalog, config)?;
62		let delay = backend.delay();
63		Ok(Self {
64			backend,
65			delay,
66			delay_remaining: delay,
67		})
68	}
69
70	/// The decoder backend name in use, e.g. `"libopus"` or `"symphonia"`.
71	pub fn name(&self) -> &str {
72		self.backend.name()
73	}
74
75	/// The rate the codec decodes at, which may differ from the catalog's.
76	pub fn sample_rate(&self) -> u32 {
77		self.backend.sample_rate()
78	}
79
80	/// The PCM layout the codec decodes to.
81	pub fn layout(&self) -> Layout {
82		self.backend.layout()
83	}
84
85	/// Reset codec history and reapply startup delay for a new discontinuous epoch.
86	pub fn reset(&mut self) -> Result<(), Error> {
87		self.reset_prediction()?;
88		self.reapply_delay();
89		Ok(())
90	}
91
92	/// Reapply catalog startup delay for a new playhead epoch without resetting codec prediction.
93	pub(super) fn reapply_delay(&mut self) {
94		self.delay_remaining = self.delay;
95	}
96
97	/// Reset codec prediction after packet loss without reapplying stream startup delay.
98	pub(super) fn reset_prediction(&mut self) -> Result<(), Error> {
99		self.backend.reset()
100	}
101
102	/// How much startup delay is still to be trimmed, in native-rate frames.
103	///
104	/// Trimmed samples are media the packet covered even though nothing came out of
105	/// it, so a caller tracking where a packet ends has to add back whatever this
106	/// dropped across the call.
107	pub(super) fn delay_remaining(&self) -> usize {
108		self.delay_remaining
109	}
110
111	/// Decode one packet into interleaved `f32` PCM and report its codec activity.
112	///
113	/// An empty Opus packet marks one lost packet and conceals as much audio as the
114	/// last packet that decoded held, so a lost 20 ms packet yields 20 ms. It is
115	/// refused with [`Error::Decode`] before any packet has decoded, since there is
116	/// no length to conceal. Loss during DTX remains classified as DTX, while loss
117	/// during active audio remains active.
118	pub fn decode(&mut self, packet: &[u8]) -> Result<Decoded, Error> {
119		let mut decoded = self.backend.decode(packet)?;
120		let channels = self.backend.layout().channels() as usize;
121		let trim = self.delay_remaining.min(decoded.samples.len() / channels);
122		if trim > 0 {
123			decoded.samples.drain(..trim * channels);
124			self.delay_remaining -= trim;
125		}
126		Ok(decoded)
127	}
128}
129
130#[cfg(test)]
131pub(crate) mod tests {
132	use super::*;
133
134	/// Three consecutive AAC-LC frames of a 440 Hz full-scale sine, mono at
135	/// 44.1 kHz, and the AudioSpecificConfig that opens them. Generated with:
136	///
137	/// ```text
138	/// 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
139	/// ```
140	///
141	/// then stripping the ADTS header off each frame, since the wire carries raw
142	/// AAC. These are frames 2 to 4, past the encoder's priming. lavfi's sine is
143	/// an eighth of full scale, which is what the volume filter is undoing.
144	#[cfg(feature = "aac")]
145	const AAC_DESCRIPTION: &[u8] = b"\x12\x08";
146
147	#[cfg(feature = "aac")]
148	const AAC_FRAMES: [&[u8]; 3] = [
149		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",
150		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",
151		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",
152	];
153
154	#[cfg(feature = "aac")]
155	fn aac_catalog() -> hang::catalog::AudioConfig {
156		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AAC { profile: 2 }, 44_100, 1);
157		catalog.description = Some(bytes::Bytes::from_static(AAC_DESCRIPTION));
158		catalog
159	}
160
161	/// The published `Decoder` was unwind safe before it boxed a backend; keep it so.
162	#[test]
163	fn decoder_is_unwind_safe() {
164		fn assert_unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
165		assert_unwind_safe::<Decoder>();
166	}
167
168	#[cfg(feature = "aac")]
169	#[test]
170	fn aac_decodes_a_sine() {
171		let mut decoder = Decoder::new(&aac_catalog(), &Config::default()).unwrap();
172		assert_eq!(decoder.name(), "symphonia");
173		assert_eq!(decoder.sample_rate(), 44_100);
174		assert_eq!(decoder.layout(), Layout::Mono);
175
176		let decoded: Vec<Vec<f32>> = AAC_FRAMES
177			.iter()
178			.map(|frame| decoder.decode(frame).unwrap().samples)
179			.collect();
180
181		// AAC-LC frames are 1024 samples each, whatever the packet size.
182		for pcm in &decoded {
183			assert_eq!(pcm.len(), 1024);
184		}
185
186		// The first frame is missing the previous frame's overlap, so measure the
187		// last one. ffmpeg decodes this same frame to 0.744 RMS, near the 0.707 of
188		// an ideal full-scale sine.
189		let last = decoded.last().unwrap();
190		let rms = (last.iter().map(|s| s * s).sum::<f32>() / last.len() as f32).sqrt();
191		assert!((0.65..0.8).contains(&rms), "expected a full-scale sine, got {rms} RMS");
192	}
193
194	#[cfg(feature = "aac")]
195	#[test]
196	fn aac_reports_a_truncated_packet_as_decode() {
197		let mut decoder = Decoder::new(&aac_catalog(), &Config::default()).unwrap();
198
199		let truncated = &AAC_FRAMES[0][..16];
200		assert!(matches!(decoder.decode(truncated), Err(Error::Decode(_))));
201	}
202
203	#[cfg(feature = "aac")]
204	#[test]
205	fn aac_synthesizes_a_missing_description() {
206		// An MSF catalog carries the shape in its own fields instead.
207		let mut catalog = aac_catalog();
208		catalog.description = None;
209
210		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
211		assert_eq!(decoder.sample_rate(), 44_100);
212		assert_eq!(decoder.decode(AAC_FRAMES[0]).unwrap().samples.len(), 1024);
213	}
214
215	/// A packet libopus rejects is that packet's problem, not the
216	/// configuration's. The distinction is what lets a consumer drop the frame and
217	/// keep the subscription instead of ending the stream over one bad packet.
218	#[test]
219	fn opus_reports_a_rejected_packet_as_decode() {
220		let head = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap();
221		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
222		catalog.description = Some(head);
223
224		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
225
226		// Not a valid TOC byte sequence: libopus reports OPUS_INVALID_PACKET.
227		assert!(matches!(decoder.decode(&[0xFF; 3]), Err(Error::Decode(_))));
228	}
229
230	/// Real Opus: 20 ms packets of a continuous 440 Hz sine, mono, from libopus
231	/// with its 312-sample lookahead.
232	pub(crate) fn opus_packets(count: usize) -> Vec<bytes::Bytes> {
233		let mut encoder = crate::encode::Encoder::new(&crate::encode::Settings::new(48_000, Layout::Mono)).unwrap();
234		let frames = encoder.frame_size();
235		(0..count)
236			.map(|packet| {
237				let pcm: Vec<f32> = (packet * frames..(packet + 1) * frames)
238					.map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / 48_000.0).sin() * 0.5)
239					.collect();
240				encoder.encode(&pcm).unwrap().payload
241			})
242			.collect()
243	}
244
245	/// A catalog shaped like an import's: the OpusHead input rate as the catalog rate.
246	pub(crate) fn opus_catalog(head: moq_mux::codec::opus::Config) -> hang::catalog::AudioConfig {
247		let mut catalog =
248			hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, head.sample_rate, head.channel_count);
249		catalog.description = Some(head.encode().unwrap());
250		catalog
251	}
252
253	fn rms(samples: &[f32]) -> f32 {
254		(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
255	}
256
257	/// The OpusHead input rate is metadata: 44.1 kHz and unknown (0) are valid
258	/// heads, and no input rate changes the 48 kHz clock the packets decode on.
259	#[test]
260	fn opus_decodes_at_48k_whatever_the_input_rate() {
261		let packets = opus_packets(2);
262		for input_rate in [0, 8_000, 24_000, 44_100, 48_000, 96_000] {
263			let head = moq_mux::codec::opus::Config::new(input_rate, 1).with_pre_skip(312);
264			let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap();
265			assert_eq!(decoder.sample_rate(), 48_000, "input rate {input_rate}");
266
267			// The pre-skip is 48 kHz samples, trimmed once.
268			assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960 - 312);
269			assert_eq!(decoder.decode(&packets[1]).unwrap().samples.len(), 960);
270		}
271	}
272
273	/// One lost packet conceals one packet's worth of audio, not the 120 ms
274	/// libopus would fill given the whole buffer.
275	#[test]
276	fn opus_conceals_the_length_of_the_last_packet() {
277		let packets = opus_packets(3);
278		let mut decoder = Decoder::new(
279			&opus_catalog(moq_mux::codec::opus::Config::new(48_000, 1)),
280			&Config::default(),
281		)
282		.unwrap();
283
284		// Nothing decoded yet, so there is no length to conceal.
285		assert!(matches!(decoder.decode(&[]), Err(Error::Decode(_))));
286
287		for packet in &packets {
288			decoder.decode(packet).unwrap();
289		}
290		assert_eq!(decoder.decode(&[]).unwrap().samples.len(), 960);
291
292		// Concealment doesn't change the length: the next loss is the same size.
293		assert_eq!(decoder.decode(&[]).unwrap().samples.len(), 960);
294	}
295
296	#[test]
297	fn opus_applies_the_declared_gain() {
298		let packets = opus_packets(5);
299		let decode = |output_gain: i16| {
300			let mut head = moq_mux::codec::opus::Config::new(48_000, 1);
301			head.output_gain = output_gain;
302			let mut decoder = Decoder::new(&opus_catalog(head), &Config::default()).unwrap();
303			let mut last = Vec::new();
304			for packet in &packets {
305				last = decoder.decode(packet).unwrap().samples;
306			}
307			// A reset after loss keeps the gain.
308			decoder.reset().unwrap();
309			let reset = decoder.decode(&packets[4]).unwrap().samples;
310			(rms(&last), rms(&reset))
311		};
312
313		// -6.02 dB in Q7.8 halves the amplitude.
314		let (plain, plain_reset) = decode(0);
315		let (quiet, quiet_reset) = decode(-1541);
316		assert!((quiet / plain - 0.5).abs() < 0.001, "gain ratio {}", quiet / plain);
317		assert!(
318			(quiet_reset / plain_reset - 0.5).abs() < 0.001,
319			"gain ratio after reset {}",
320			quiet_reset / plain_reset
321		);
322	}
323
324	/// A present description is the stream's configuration, so a broken one is
325	/// refused rather than replaced by the catalog's fields.
326	#[test]
327	fn opus_refuses_a_malformed_description() {
328		let valid = moq_mux::codec::opus::Config::new(48_000, 2).encode().unwrap().to_vec();
329		let mut version = valid.clone();
330		version[8] = 16;
331		let mut channels = valid.clone();
332		channels[9] = 3;
333		let mut signature = valid.clone();
334		signature[0] = b'X';
335		// Family 1 promising a table that is not there.
336		let mut table = valid.clone();
337		table[18] = 1;
338
339		for (name, description) in [
340			("truncated", valid[..18].to_vec()),
341			("empty", Vec::new()),
342			("signature", signature),
343			("version", version),
344			("channels", channels),
345			("table", table),
346		] {
347			let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2);
348			catalog.description = Some(description.into());
349			assert!(
350				matches!(Decoder::new(&catalog, &Config::default()), Err(Error::Unsupported(_))),
351				"{name}"
352			);
353		}
354	}
355
356	/// Without a description the catalog shapes the stream, which has no pre-skip.
357	#[test]
358	fn opus_decodes_without_a_description() {
359		let packets = opus_packets(1);
360		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 24_000, 1);
361		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
362		assert_eq!(decoder.sample_rate(), 48_000);
363		assert_eq!(decoder.decode(&packets[0]).unwrap().samples.len(), 960);
364
365		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 6);
366		assert!(matches!(
367			Decoder::new(&catalog, &Config::default()),
368			Err(Error::Unsupported(_))
369		));
370	}
371
372	#[test]
373	fn pcm_rejects_incomplete_channel_frame() {
374		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
375		let mut decoder = Decoder::new(&catalog, &Config::default()).unwrap();
376
377		assert!(matches!(
378			decoder.decode(&[]),
379			Err(Error::Misaligned { got: 0, expected: 8 })
380		));
381		assert!(matches!(
382			decoder.decode(&[0; 4]),
383			Err(Error::Misaligned { got: 4, expected: 8 })
384		));
385	}
386
387	#[test]
388	fn decoder_rejects_unknown_codec() {
389		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Unknown("future".into()), 48_000, 2);
390
391		assert!(matches!(
392			Decoder::new(&catalog, &Config::default()),
393			Err(Error::Unsupported(_))
394		));
395	}
396
397	#[test]
398	fn pcm_rejects_incorrect_catalog_bitrate() {
399		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
400		catalog.bitrate = Some(1);
401
402		assert!(matches!(
403			Decoder::new(&catalog, &Config::default()),
404			Err(Error::Unsupported(_))
405		));
406	}
407
408	/// Published moq-audio accepts the codec's name, not a backend's.
409	#[cfg(feature = "aac")]
410	#[test]
411	fn named_aac_decodes_aac() {
412		let config = Config {
413			kind: Kind::Named("aac".into()),
414		};
415		assert!(Decoder::new(&aac_catalog(), &config).is_ok());
416
417		let config = Config {
418			kind: Kind::Named("opus".into()),
419		};
420		assert!(matches!(
421			Decoder::new(&aac_catalog(), &config),
422			Err(Error::Unsupported(_))
423		));
424	}
425
426	#[test]
427	fn refuses_unavailable_backend() {
428		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 2);
429		let config = Config {
430			kind: Kind::Named("missing".into()),
431		};
432		assert!(matches!(Decoder::new(&catalog, &config), Err(Error::Unsupported(_))));
433	}
434}