Skip to main content

moq_audio/decode/
consumer.rs

1//! Subscribe to an encoded audio track and emit raw PCM.
2
3use std::collections::VecDeque;
4
5use bytes::Bytes;
6
7use super::decoder::{Config, Decoder};
8use crate::resample::{Resampler, remix, validate_channels};
9use crate::{Activity, Error, Frame};
10
11/// Subscribe to a moq-mux audio track and emit decoded PCM in the layout
12/// declared by [`Config`].
13///
14/// The mirror of [`encode::Producer`](crate::encode::Producer): output format /
15/// sample rate / channel count are fixed at construction, and
16/// [`read`](Self::read) returns [`Frame`]s carrying the codec activity they
17/// were decoded from.
18pub struct Consumer {
19	decoder: Decoder,
20	track: moq_mux::container::Consumer<moq_mux::catalog::hang::Container>,
21	resampler: Option<Resampler>,
22	config: Config,
23	resolved_sample_rate: u32,
24	resolved_channels: u32,
25	/// One past the last sample handed to the resampler, so the tail it is still
26	/// holding at end of track can be stamped. `None` until the first packet.
27	tail: Option<moq_net::Timestamp>,
28	/// Codec activity spans the resampler's buffered output still covers.
29	spans: VecDeque<ActivitySpan>,
30	/// Activity of the last span the output ran past, for the rounding samples the
31	/// filter leaves beyond the final input boundary.
32	trailing: Activity,
33	/// Timestamp of the first encoded packet, used to interpret a terminal marker.
34	epoch: Option<moq_net::Timestamp>,
35	/// Codec-rate terminal frames emitted since `terminal_start`.
36	frames_decoded: usize,
37	/// Logical endpoint carried by an empty legacy frame before terminal packets.
38	end: Option<moq_net::Timestamp>,
39	/// Presentation time of the first decoded terminal frame.
40	terminal_start: Option<moq_net::Timestamp>,
41	/// Last container discontinuity applied to codec and resampler state.
42	discontinuity: u64,
43}
44
45struct ActivitySpan {
46	end: moq_net::Timestamp,
47	activity: Activity,
48}
49
50impl Consumer {
51	/// Subscribe to `name` in `broadcast`, using the catalog entry to pick the
52	/// codec.
53	pub async fn new(
54		broadcast: &moq_net::broadcast::Consumer,
55		catalog: &hang::catalog::AudioConfig,
56		name: impl Into<String>,
57		config: Config,
58	) -> Result<Self, Error> {
59		let decoder = Decoder::new(catalog)?;
60		let sample_rate = config.sample_rate.unwrap_or_else(|| decoder.sample_rate());
61		let channels = config.channels.unwrap_or_else(|| decoder.channel_count());
62		validate_channels(channels)?;
63
64		let resampler = if sample_rate == decoder.sample_rate() {
65			None
66		} else {
67			let chunk_frames = (decoder.sample_rate() as usize * 20) / 1000;
68			Some(Resampler::new(
69				decoder.sample_rate(),
70				sample_rate,
71				decoder.channel_count(),
72				chunk_frames,
73			)?)
74		};
75
76		let name = name.into();
77		let track = broadcast
78			.track(&name)?
79			.subscribe(moq_net::track::Subscription::default().with_priority(hang::catalog::PRIORITY.audio))
80			.await?;
81		// The catalog says how the track is framed, and it is not always the legacy
82		// wire: `moq import fmp4` publishes CMAF. Reading a moof+mdat fragment as a
83		// varint timestamp plus a payload decodes to garbage rather than failing.
84		let container = moq_mux::catalog::hang::Container::try_from(&catalog.container)?;
85		let mut track = moq_mux::container::Consumer::new(track, container);
86		if let Some(latency) = config.latency_max {
87			track = track.with_latency(latency);
88		}
89
90		Ok(Self {
91			decoder,
92			track,
93			resampler,
94			config,
95			resolved_sample_rate: sample_rate,
96			resolved_channels: channels,
97			tail: None,
98			spans: VecDeque::new(),
99			trailing: Activity::Active,
100			epoch: None,
101			frames_decoded: 0,
102			end: None,
103			terminal_start: None,
104			discontinuity: 0,
105		})
106	}
107
108	/// The config this consumer was built with.
109	pub fn config(&self) -> &Config {
110		&self.config
111	}
112
113	/// Sample rate samples are actually delivered at, which is
114	/// [`Config::sample_rate`] resolved against the catalog.
115	pub fn sample_rate(&self) -> u32 {
116		self.resolved_sample_rate
117	}
118
119	/// Channel count samples are actually delivered at, which is
120	/// [`Config::channels`] resolved against the catalog.
121	pub fn channels(&self) -> u32 {
122		self.resolved_channels
123	}
124
125	/// Read the next decoded PCM frame, or `None` when the track ends.
126	///
127	/// [`Frame::activity`] reports whether the packet these samples came from
128	/// coded audio. It describes where the frame begins, so a resampled
129	/// frame that straddles a change carries the activity its first sample came
130	/// from and the next frame carries the new one.
131	pub async fn read(&mut self) -> Result<Option<Frame>, Error> {
132		loop {
133			let mux_frame = self.track.read().await?;
134			self.apply_discontinuity()?;
135			let Some(mux_frame) = mux_frame else {
136				return self.flush();
137			};
138
139			if let Some(end) = self.track.end()
140				&& self.end != Some(end)
141			{
142				self.end = Some(end);
143				self.frames_decoded = 0;
144				self.terminal_start = None;
145			}
146
147			let rate = self.decoder.sample_rate();
148			let epoch = *self.epoch.get_or_insert(mux_frame.timestamp);
149			let decoded = self.decoder.decode(&mux_frame.payload)?;
150			let activity = decoded.activity;
151			let mut decoded = decoded.samples;
152			if let Some(end) = self.end {
153				let terminal_start = *self
154					.terminal_start
155					.get_or_insert(rewind(mux_frame.timestamp, self.decoder.delay(), rate)?.max(epoch));
156				let total = frames_between(terminal_start, end, rate)?;
157				let remaining = total.saturating_sub(self.frames_decoded);
158				decoded.truncate(remaining.saturating_mul(self.decoder.channel_count() as usize));
159			}
160
161			let frames = decoded.len() / self.decoder.channel_count().max(1) as usize;
162			let decoded_at = if let Some(terminal_start) = self.terminal_start {
163				advance(terminal_start, self.frames_decoded, rate)?
164			} else {
165				mux_frame.timestamp
166			};
167			if self.end.is_some() {
168				self.frames_decoded += frames;
169			}
170			if decoded.is_empty() {
171				continue;
172			}
173
174			let (pcm, timestamp) = match self.resampler.as_mut() {
175				// The resampler works in fixed chunks, so it holds back whatever didn't
176				// fill one. What comes out next starts with those held-back samples, which
177				// arrived before this packet did: stamping it with this packet's timestamp
178				// would place the audio late by up to a chunk, sawtoothing A/V sync.
179				Some(r) => {
180					let pending = r.pending_frames();
181					let skipped = r.skipped();
182					let pcm = r.process(&decoded)?;
183					(pcm, self.starts_at(decoded_at, pending, skipped, rate)?)
184				}
185				None => (decoded, decoded_at),
186			};
187
188			let decoded_end = advance(decoded_at, frames, rate)?;
189			self.tail = Some(decoded_end);
190
191			// The resampler hands back samples it was holding from earlier packets,
192			// so what comes out starts before the packet that filled its chunk. Track
193			// where each packet's activity ends so the output can be labelled by
194			// where it actually begins, not by the packet just submitted.
195			let resampled = self.resampler.is_some();
196			if resampled {
197				self.spans.push_back(ActivitySpan {
198					end: decoded_end,
199					activity,
200				});
201			}
202
203			// A packet shorter than the resampler's chunk leaves nothing to hand
204			// over yet. Read on rather than returning a frame with no samples, which
205			// a caller would otherwise see as audio arriving.
206			if pcm.is_empty() {
207				continue;
208			}
209
210			let activity = if resampled {
211				self.activity_at(timestamp)
212			} else {
213				activity
214			};
215			return Ok(Some(self.frame(pcm, timestamp, activity)?));
216		}
217	}
218
219	/// Reset every stateful decode stage before the first packet of a new epoch.
220	fn apply_discontinuity(&mut self) -> Result<(), Error> {
221		let discontinuity = self.track.discontinuity();
222		if discontinuity == self.discontinuity {
223			return Ok(());
224		}
225
226		self.discontinuity = discontinuity;
227		self.decoder.reset()?;
228		if let Some(resampler) = self.resampler.as_mut() {
229			resampler.reset();
230		}
231		self.tail = None;
232		self.spans.clear();
233		self.trailing = Activity::Active;
234		self.epoch = None;
235		self.frames_decoded = 0;
236		self.end = None;
237		self.terminal_start = None;
238		Ok(())
239	}
240
241	/// The tail the resampler is still holding when the track ends, once.
242	///
243	/// Without it the last partial chunk is dropped, which is up to a chunk of
244	/// audio missing from the end of every resampled track. Flushing consumes the
245	/// resampler, which is what makes calling this on every later poll return
246	/// `None` rather than more tails.
247	fn flush(&mut self) -> Result<Option<Frame>, Error> {
248		let (Some(resampler), Some(tail)) = (self.resampler.take(), self.tail) else {
249			return Ok(None);
250		};
251
252		let pending = resampler.pending_frames();
253		let skipped = resampler.skipped();
254		let pcm = resampler.flush()?;
255		if pcm.is_empty() {
256			return Ok(None);
257		}
258
259		let timestamp = self.starts_at(tail, pending, skipped, self.decoder.sample_rate())?;
260		let activity = self.activity_at(timestamp);
261		Ok(Some(self.frame(pcm, timestamp, activity)?))
262	}
263
264	/// The codec activity covering `timestamp`, dropping the spans it has passed.
265	fn activity_at(&mut self, timestamp: moq_net::Timestamp) -> Activity {
266		while let Some(span) = self.spans.front().filter(|span| span.end <= timestamp) {
267			self.trailing = span.activity;
268			self.spans.pop_front();
269		}
270
271		self.spans.front().map_or(self.trailing, |span| span.activity)
272	}
273
274	/// Where the output the resampler is about to hand back actually begins.
275	///
276	/// Two things sit between a packet's timestamp and the audio that comes out of
277	/// it. The resampler is holding `pending` input frames from before this packet,
278	/// which the output starts with. And it has dropped `skipped` output frames of
279	/// its own startup silence, so everything it emits from then on runs that much
280	/// short of the input it was built from. Reach back over both, each in its own
281	/// rate, or the output is stamped after the audio it contains.
282	fn starts_at(
283		&self,
284		timestamp: moq_net::Timestamp,
285		pending: usize,
286		skipped: usize,
287		rate: u32,
288	) -> Result<moq_net::Timestamp, Error> {
289		let timestamp = rewind(timestamp, pending, rate)?;
290		rewind(timestamp, skipped, self.resolved_sample_rate)
291	}
292
293	/// Remix and pack decoded PCM into an output frame.
294	fn frame(&self, pcm: Vec<f32>, timestamp: moq_net::Timestamp, activity: Activity) -> Result<Frame, Error> {
295		let pcm = if self.decoder.channel_count() == self.resolved_channels {
296			pcm
297		} else {
298			remix(&pcm, self.decoder.channel_count(), self.resolved_channels)?
299		};
300
301		let bytes = self.config.format.from_interleaved_f32(&pcm, self.resolved_channels)?;
302		Ok(Frame {
303			timestamp,
304			data: Bytes::from(bytes),
305			activity,
306		})
307	}
308}
309
310/// `timestamp` moved forward by `frames` at `sample_rate`, in its own timescale.
311fn advance(timestamp: moq_net::Timestamp, frames: usize, sample_rate: u32) -> Result<moq_net::Timestamp, Error> {
312	if frames == 0 {
313		return Ok(timestamp);
314	}
315
316	let offset = moq_net::Timestamp::from_scale(frames as u64, sample_rate as u64)?.convert(timestamp.scale())?;
317	Ok(timestamp.checked_add(offset)?)
318}
319
320/// Codec-rate frames in the interval, rounding a microsecond marker to the nearest frame.
321fn frames_between(start: moq_net::Timestamp, end: moq_net::Timestamp, sample_rate: u32) -> Result<usize, Error> {
322	let duration = end.checked_sub(start)?;
323	let frames = (std::time::Duration::from(duration).as_nanos() * sample_rate as u128 + 500_000_000) / 1_000_000_000;
324	usize::try_from(frames).map_err(|_| Error::Unsupported("audio duration does not fit in memory".into()))
325}
326
327/// `timestamp` moved back by `frames` at `sample_rate`, in its own timescale.
328///
329/// Saturates at zero rather than failing: a publisher whose first timestamps
330/// don't advance is odd, but it isn't a reason to end the track.
331fn rewind(timestamp: moq_net::Timestamp, frames: usize, sample_rate: u32) -> Result<moq_net::Timestamp, Error> {
332	if frames == 0 {
333		return Ok(timestamp);
334	}
335
336	let offset = moq_net::Timestamp::from_scale(frames as u64, sample_rate as u64)?.convert(timestamp.scale())?;
337	Ok(timestamp
338		.checked_sub(offset)
339		.unwrap_or(moq_net::Timestamp::new(0, timestamp.scale())?))
340}
341
342#[cfg(test)]
343mod tests {
344	use moq_net::Timestamp;
345
346	use super::*;
347	use crate::Format;
348	use crate::encode::{Encoder, Input, Options, Producer};
349
350	#[tokio::test]
351	async fn remixes_mono_stream_to_stereo_output() {
352		let mut broadcast = moq_net::broadcast::Info::new().produce();
353		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
354		let subscriber = broadcast.consume();
355		let input = Input {
356			format: Format::F32,
357			sample_rate: 48_000,
358			channels: 1,
359		};
360		let options = Options {
361			track: Some("audio".to_string()),
362			..Options::default()
363		};
364		let mut producer = Producer::new(&mut broadcast, catalog, input.clone(), &options).unwrap();
365		let catalog = Encoder::new(&crate::encode::Config::new(input)).unwrap().catalog();
366		let mut consumer = Consumer::new(
367			&subscriber,
368			&catalog,
369			"audio",
370			Config {
371				channels: Some(2),
372				..Config::new()
373			},
374		)
375		.await
376		.unwrap();
377
378		let samples = vec![0.1f32; 960];
379		let mut data = Vec::with_capacity(samples.len() * size_of::<f32>());
380		for sample in samples {
381			data.extend_from_slice(&sample.to_le_bytes());
382		}
383		producer.write(&Frame::new(data.into(), Timestamp::ZERO)).unwrap();
384
385		let frame = consumer.read().await.unwrap().expect("decoded frame");
386		let samples = Format::F32.as_interleaved_f32(&frame.data, 2).unwrap();
387		assert_eq!(samples.len(), (960 - 312) * 2);
388		for pair in samples.chunks_exact(2) {
389			assert_eq!(pair[0], pair[1]);
390		}
391	}
392
393	/// A packet whose sample count isn't a multiple of the resampler's chunk leaves
394	/// samples buffered, and the next output starts with those. Stamping that
395	/// output with the packet that completed the chunk puts it up to a chunk late,
396	/// which is a sawtooth in A/V sync rather than a constant offset. Any codec
397	/// whose frame is not a whole number of chunks reaches it: a 1024-sample frame
398	/// at 44.1 kHz never fills the 882-frame chunk evenly.
399	#[tokio::test]
400	async fn resampled_timestamps_follow_the_samples() {
401		let mut broadcast = moq_net::broadcast::Info::new().produce();
402		let track = broadcast.create_track("audio", hang::container::track_info()).unwrap();
403		let subscriber = broadcast.consume();
404
405		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 44_100, 1);
406		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
407
408		let mut consumer = Consumer::new(
409			&subscriber,
410			&catalog,
411			"audio",
412			Config {
413				sample_rate: Some(48_000),
414				..Config::new()
415			},
416		)
417		.await
418		.unwrap();
419
420		// Two 1024-sample packets, back to back at the codec's own rate.
421		const FRAMES: u64 = 1024;
422		let payload: Bytes = vec![0u8; FRAMES as usize * size_of::<f32>()].into();
423		for packet in 0..2 {
424			producer
425				.write(moq_mux::container::Frame {
426					timestamp: moq_net::Timestamp::from_scale(packet * FRAMES, 44_100).unwrap(),
427					duration: None,
428					payload: payload.clone(),
429					keyframe: true,
430				})
431				.unwrap();
432		}
433
434		let first = consumer.read().await.unwrap().expect("decoded frame");
435		assert_eq!(first.timestamp.as_micros(), 0);
436
437		// Continuity, not a fixed number: the second frame starts where the first
438		// one's samples end, whatever they came to. Within a few frames rather than
439		// exactly, because the resampler emits whole frames and its count per chunk
440		// wobbles around the nominal ratio; a real hole (the samples it held back, or
441		// the startup silence it dropped) is twenty times this tolerance.
442		let second = consumer.read().await.unwrap().expect("decoded frame");
443		let first_frames = (first.data.len() / size_of::<f32>()) as u128;
444		let ends_at = first_frames * 1_000_000 / 48_000;
445		let gap = second.timestamp.as_micros().abs_diff(ends_at);
446		assert!(gap < 100, "expected the frames to meet, got a {gap} us gap");
447	}
448
449	/// The resampler only converts whole chunks, so the last partial one has to be
450	/// flushed at end of track or its audio is simply gone. A 1024-sample frame at
451	/// 44.1 kHz guarantees a remainder, never filling the 882-frame chunk evenly.
452	#[tokio::test]
453	async fn resampled_tail_survives_the_end_of_the_track() {
454		let mut broadcast = moq_net::broadcast::Info::new().produce();
455		let track = broadcast.create_track("audio", hang::container::track_info()).unwrap();
456		let subscriber = broadcast.consume();
457
458		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 44_100, 1);
459		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
460
461		let mut consumer = Consumer::new(
462			&subscriber,
463			&catalog,
464			"audio",
465			Config {
466				sample_rate: Some(48_000),
467				..Config::new()
468			},
469		)
470		.await
471		.unwrap();
472
473		// One 1024-frame packet: 882 fill a chunk, 142 are left holding.
474		const FRAMES: usize = 1024;
475		let payload: Bytes = vec![0u8; FRAMES * size_of::<f32>()].into();
476		producer
477			.write(moq_mux::container::Frame {
478				timestamp: moq_net::Timestamp::ZERO,
479				duration: None,
480				payload,
481				keyframe: true,
482			})
483			.unwrap();
484		producer.finish().unwrap();
485
486		let first = consumer.read().await.unwrap().expect("decoded frame");
487		let first_frames = first.data.len() / size_of::<f32>();
488
489		let tail = consumer.read().await.unwrap().expect("flushed tail");
490		let tail_frames = tail.data.len() / size_of::<f32>();
491
492		// The 142 held-back frames at 44.1 kHz are ~155 at 48 kHz, plus the 69 the
493		// sinc filter still owes: it runs centred, so the end of the track only
494		// emerges once the flush has fed it silence to push it out.
495		assert!((215..=230).contains(&tail_frames), "unexpected tail: {tail_frames}");
496		// It picks up where the first frame's samples ended, within the same few
497		// frames of whole-frame rounding as above.
498		let ends_at = (first_frames as u128) * 1_000_000 / 48_000;
499		let gap = tail.timestamp.as_micros().abs_diff(ends_at);
500		assert!(gap < 100, "expected the tail to meet the body, got a {gap} us gap");
501
502		// Together they cover the packet and no more: 1024 frames at 44.1 kHz is
503		// ~1114 at 48 kHz. The filter's delay does not extend the stream, because
504		// what the drain adds here is what the start dropped off the front.
505		let total = first_frames + tail_frames;
506		assert!((1105..=1120).contains(&total), "unexpected total: {total}");
507		assert!(consumer.read().await.unwrap().is_none());
508	}
509
510	#[tokio::test]
511	async fn resampling_keeps_the_activity_boundary_on_its_source() {
512		let mut encoder = Encoder::new(&crate::encode::Config {
513			dtx: true,
514			bitrate: Some(24_000),
515			frame_duration: std::time::Duration::from_millis(10),
516			..crate::encode::Config::new(Input {
517				channels: 1,
518				..Input::default()
519			})
520		})
521		.unwrap();
522		let catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 1);
523
524		let mut broadcast = moq_net::broadcast::Info::new().produce();
525		let track = broadcast.create_track("audio", hang::container::track_info()).unwrap();
526		let subscriber = broadcast.consume();
527		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Legacy);
528		let mut consumer = Consumer::new(
529			&subscriber,
530			&catalog,
531			"audio",
532			Config {
533				sample_rate: Some(44_100),
534				..Config::new()
535			},
536		)
537		.await
538		.unwrap();
539
540		let active = vec![0.5; encoder.frame_size()];
541		let silence = vec![0.0; encoder.frame_size()];
542		let mut first_dtx = None;
543		for index in 0..40u64 {
544			let packet = encoder.encode(if index == 0 { &active } else { &silence }).unwrap();
545			let timestamp = Timestamp::from_scale(index * encoder.frame_size() as u64, 48_000).unwrap();
546			if first_dtx.is_none() && packet.activity.is_dtx() {
547				first_dtx = Some(timestamp);
548			}
549			producer
550				.write(moq_mux::container::Frame {
551					timestamp,
552					payload: packet.payload,
553					keyframe: true,
554					duration: None,
555				})
556				.unwrap();
557			producer.cut(None).unwrap();
558		}
559		producer.finish().unwrap();
560
561		let expected = first_dtx.expect("silence should enter Opus DTX");
562		let mut actual = None;
563		while let Some(frame) = consumer.read().await.unwrap() {
564			// 10 ms packets do not fill the 20 ms chunk, so the resampler hands back
565			// nothing every other packet. Those must not surface as frames: a frame
566			// with no samples reads as audio arriving, and carries an activity
567			// describing samples that are not there.
568			assert!(!frame.data.is_empty(), "read returned a frame with no samples");
569			if frame.activity.is_dtx() {
570				actual = Some(frame.timestamp);
571				break;
572			}
573		}
574		let actual = actual.expect("consumer should report Opus DTX");
575
576		// Each frame carries the activity its first sample came from, so the label
577		// can lag its source by up to the frame it lands in, but it must never lead
578		// it: leading means samples that are still active got labelled DTX. That is
579		// what labelling by the packet most recently submitted does, since the
580		// resampler is handing back audio from before that packet. It puts the
581		// boundary a chunk early instead of a fraction of a chunk late.
582		let delay = actual.as_micros() as i128 - expected.as_micros() as i128;
583		let chunk_us = 20_000i128;
584		assert!(
585			(0..chunk_us).contains(&delay),
586			"DTX label landed {delay} us from its source, outside [0, {chunk_us})"
587		);
588	}
589
590	#[tokio::test]
591	async fn reads_the_container_the_catalog_declares() {
592		let mut broadcast = moq_net::broadcast::Info::new().produce();
593		let track = broadcast.create_track("audio", hang::container::track_info()).unwrap();
594		let subscriber = broadcast.consume();
595
596		let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 1);
597		catalog.container = hang::catalog::Container::Loc;
598
599		let mut producer = moq_mux::container::Producer::new(track, moq_mux::catalog::hang::Container::Loc);
600		let mut consumer = Consumer::new(
601			&subscriber,
602			&catalog,
603			"audio",
604			Config {
605				format: Format::F32,
606				..Config::new()
607			},
608		)
609		.await
610		.unwrap();
611
612		let samples = [0.25f32, -0.5, 0.75, -1.0];
613		let payload: Vec<u8> = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect();
614		producer
615			.write(moq_mux::container::Frame {
616				timestamp: Timestamp::ZERO,
617				duration: None,
618				payload: payload.into(),
619				keyframe: true,
620			})
621			.unwrap();
622
623		let frame = consumer.read().await.unwrap().expect("decoded frame");
624		assert_eq!(
625			Format::F32.as_interleaved_f32(&frame.data, 1).unwrap().as_ref(),
626			samples
627		);
628	}
629
630	/// The catalog picks the framing, not this crate. Hardcoding the legacy wire
631	/// read a CMAF fragment as a varint timestamp plus a payload, which handed the
632	/// codec garbage instead of failing, so anything published by `moq import
633	/// fmp4` was undecodable.
634	#[tokio::test]
635	async fn decodes_a_cmaf_framed_track() {
636		let input = Input {
637			format: Format::F32,
638			sample_rate: 48_000,
639			channels: 2,
640		};
641
642		// One real Opus packet, so a mis-framed read can't accidentally decode.
643		let mut encoder = Encoder::new(&crate::encode::Config::new(input.clone())).unwrap();
644		let mut catalog = encoder.catalog();
645		let pcm = vec![0.0f32; encoder.frame_size() * encoder.codec_channels() as usize];
646		let packet = encoder.encode(&pcm).unwrap();
647
648		// Re-describe the same rendition as CMAF and publish it that way.
649		let muxer = moq_mux::container::fmp4::Muxer::audio(&catalog).unwrap();
650		let init = muxer.init().unwrap().expect("an out-of-band codec has an init segment");
651		catalog.container = hang::catalog::Container::Cmaf { init };
652
653		let mut broadcast = moq_net::broadcast::Info::new().produce();
654		let subscriber = broadcast.consume();
655		let track = broadcast.create_track("audio", hang::container::track_info()).unwrap();
656		let container = moq_mux::catalog::hang::Container::try_from(&catalog.container).unwrap();
657		let mut producer = moq_mux::container::Producer::new(track, container);
658
659		let mut consumer = Consumer::new(&subscriber, &catalog, "audio", Config::new())
660			.await
661			.unwrap();
662
663		producer
664			.write(moq_mux::container::Frame {
665				timestamp: Timestamp::ZERO,
666				payload: packet.payload,
667				keyframe: true,
668				duration: None,
669			})
670			.unwrap();
671		producer.cut(None).unwrap();
672
673		// The whole packet decodes: one 20 ms Opus frame at 48 kHz, less the pre-skip
674		// trimmed off the first packet. Reading the fragment as legacy hands the codec
675		// a slice of the moof instead, which still decodes, just to a shorter buffer.
676		let frame = consumer.read().await.unwrap().expect("decoded frame");
677		// `as_micros`, not `==`: the CMAF path carries the fmp4 timescale and
678		// `Timestamp`'s equality is structural, so the scales would have to match too.
679		assert_eq!(frame.timestamp.as_micros(), 0);
680		let samples = Format::F32.as_interleaved_f32(&frame.data, 2).unwrap();
681		assert_eq!(samples.len(), (960 - 312) * 2);
682	}
683}