Skip to main content

moq_audio/encode/
producer.rs

1//! Encode raw PCM and publish it as a moq audio track.
2
3use std::time::Duration;
4
5use bytes::Bytes;
6
7use moq_mux::catalog::hang::CatalogExt;
8use moq_mux::container::Frame as MuxFrame;
9use moq_net::Timestamp;
10
11use super::encoded::Encoded;
12use super::encoder::{Codec, Config, Encoder, Input};
13use crate::resample::Resampler;
14use crate::{Activity, Error, Frame};
15
16/// Source-agnostic encode knobs for [`Producer`] and `publish_capture`, where
17/// the input PCM layout comes from the caller's frames or the capture source
18/// rather than from these options. For the bring-your-own-PCM
19/// [`Encoder`](super::Encoder), which needs that layout up front, use
20/// [`Config`](super::Config) instead.
21///
22/// `#[non_exhaustive]`: construct via [`Options::default`] and set fields, so
23/// new knobs can be added without breaking callers.
24#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct Options {
27	/// Track name to publish under. `None` derives a unique one from the codec
28	/// (`0.opus`, then `1.opus`, ...), matching how the video side names its
29	/// track. Subscribers find it through the catalog either way.
30	pub track: Option<String>,
31	/// Output codec. Defaults to [`Codec::Opus`].
32	pub codec: Codec,
33	/// Sample rate the codec runs at. `None` snaps the input rate up to the
34	/// nearest rate the codec supports, resampling if that moved it.
35	pub sample_rate: Option<u32>,
36	/// Channel count the codec runs at. `None` matches the input; anything else
37	/// is rejected, since remapping isn't implemented.
38	pub channels: Option<u32>,
39	/// Bitrate in bits per second. `None` lets Opus pick. PCM requires `None`
40	/// because its bitrate is fixed by the sample rate and channel count.
41	pub bitrate: Option<u32>,
42	/// Enable Opus in-band forward error correction.
43	pub fec: bool,
44	/// Enable Opus discontinuous transmission during silence.
45	pub dtx: bool,
46	/// Encoded frame duration. Opus accepts 2.5 / 5 / 10 / 20 / 40 / 60 ms.
47	/// PCM accepts any duration containing a whole number of samples.
48	pub frame_duration: Duration,
49}
50
51impl Default for Options {
52	fn default() -> Self {
53		Self {
54			track: None,
55			codec: Codec::default(),
56			sample_rate: None,
57			channels: None,
58			bitrate: None,
59			fec: false,
60			dtx: false,
61			frame_duration: Duration::from_millis(20),
62		}
63	}
64}
65
66impl Options {
67	/// The [`Config`] these options describe once `input`'s layout is known.
68	fn config(&self, input: Input) -> Config {
69		Config {
70			input,
71			codec: self.codec,
72			sample_rate: self.sample_rate,
73			channels: self.channels,
74			bitrate: self.bitrate,
75			fec: self.fec,
76			dtx: self.dtx,
77			frame_duration: self.frame_duration,
78		}
79	}
80}
81
82/// Encode raw PCM and publish it as a moq-mux audio track.
83///
84/// The input PCM layout is fixed at construction via [`Input`]; the codec
85/// settings via [`Options`]. Subsequent [`write`](Self::write) calls just pass a
86/// [`Frame`]: payload bytes and a timestamp.
87///
88/// The catalog rendition is registered at construction (not on first write), so
89/// a subscriber that opens the catalog before any frames arrive still sees the
90/// track.
91pub struct Producer<E: CatalogExt = ()> {
92	encoder: Encoder,
93	resampler: Option<Resampler>,
94	track: moq_mux::container::Producer<moq_mux::container::legacy::Wire>,
95	/// Owns the catalog rendition, retiring it when this producer goes away.
96	rendition: Rendition<E>,
97	pending: Vec<f32>,
98	/// Samples emitted since the current epoch (reset by [`reset_epoch`](Self::reset_epoch)).
99	frames_produced: u64,
100	/// Wall-clock anchor in microseconds, taken from the first frame after each
101	/// (re)start. Emitted PTS = `epoch + frames_produced / codec_rate`. `None`
102	/// until the first write so the next frame re-anchors to its timestamp.
103	epoch_us: Option<u64>,
104	/// An encoder reset that still needs an empty group before its next packet.
105	pending_discontinuity: bool,
106	/// Whether an empty group already separates the next packet from prior codec state.
107	decoder_boundary: bool,
108	/// How the encoder classified the packet it published most recently.
109	activity: Activity,
110}
111
112struct Terminal {
113	packets: Vec<Encoded>,
114	end: Timestamp,
115	start: Timestamp,
116	frame_size: usize,
117	codec_rate: u32,
118}
119
120/// A published track whose PCM layout is not known yet.
121///
122/// The track exists in the broadcast immediately, so its name and subscriber
123/// state are available, while the catalog rendition waits for
124/// [`encode`](Self::encode) to supply the layout it describes. Unlike a catalog
125/// [`Reserved`](moq_mux::catalog::Reserved) this does not withhold the catalog:
126/// subscribers see the broadcast without this rendition until it resolves.
127pub(crate) struct Reserved<E: CatalogExt = ()> {
128	catalog: moq_mux::catalog::Producer<E>,
129	track: moq_mux::container::Producer<moq_mux::container::legacy::Wire>,
130	name: String,
131}
132
133impl<E: CatalogExt> Reserved<E> {
134	pub(crate) fn new(
135		broadcast: &mut moq_net::broadcast::Producer,
136		catalog: moq_mux::catalog::Producer<E>,
137		options: &Options,
138	) -> Result<Self, Error> {
139		let track = match &options.track {
140			// The catalog's info carries the microsecond timescale audio hang frames stamp, so
141			// Lite05 subscribers know what scale to expect and the model layer accepts
142			// Frame::timestamp on append, plus whatever retention the broadcast declared.
143			Some(name) => broadcast.create_track(name.clone(), catalog.track_info())?,
144			// Mirrors the video side, which derives a unique name from the codec
145			// rather than making every caller invent one.
146			None => broadcast.unique_track(&format!(".{}", options.codec), catalog.track_info())?,
147		};
148		let name = track.name().to_string();
149		let track = catalog.media_producer(track, moq_mux::container::legacy::Wire)?;
150
151		Ok(Self { catalog, track, name })
152	}
153
154	/// Build the encoder for `input` and register the rendition describing it.
155	///
156	/// Separate from [`encode`](Self::encode), which cannot fail, so a layout the
157	/// codec rejects (more channels than Opus takes, a mismatch with
158	/// [`Options::channels`]) leaves the reservation intact for another input.
159	pub(crate) fn register(&mut self, input: Input, options: &Options) -> Result<Registered, Error> {
160		let encoder = Encoder::new(&options.config(input))?;
161		let input = &encoder.config().input;
162
163		let resampler = if input.sample_rate == encoder.codec_rate() {
164			None
165		} else {
166			// Use microsecond precision so 2.5 ms frame_duration (supported by
167			// libopus) doesn't truncate to 2 ms.
168			let chunk_frames =
169				((input.sample_rate as u128 * encoder.config().frame_duration.as_micros()) / 1_000_000) as usize;
170			Some(Resampler::new(
171				input.sample_rate,
172				encoder.codec_rate(),
173				input.channels,
174				chunk_frames,
175			)?)
176		};
177
178		let mut config = encoder.catalog();
179		config.timeline = Some(self.catalog.timeline(&self.name)?.section());
180		self.catalog.lock().audio.insert(&self.name, config)?;
181
182		Ok(Registered { encoder, resampler })
183	}
184
185	/// Spend the reservation on a registered encoder, publishing through the track.
186	pub(crate) fn encode(self, registered: Registered) -> Producer<E> {
187		Producer {
188			encoder: registered.encoder,
189			resampler: registered.resampler,
190			track: self.track,
191			rendition: Rendition {
192				catalog: self.catalog,
193				name: self.name,
194			},
195			pending: Vec::new(),
196			frames_produced: 0,
197			epoch_us: None,
198			pending_discontinuity: false,
199			decoder_boundary: true,
200			activity: Activity::Active,
201		}
202	}
203}
204
205/// A registered rendition, waiting for its [`Reserved`] to be spent on it.
206///
207/// Proof that [`Reserved::register`] succeeded, so [`Reserved::encode`] takes no
208/// fallible step and cannot strand the track it consumes.
209pub(crate) struct Registered {
210	encoder: Encoder,
211	resampler: Option<Resampler>,
212}
213
214/// What a capture publication needs while its layout is still undiscovered.
215#[cfg(feature = "capture")]
216impl<E: CatalogExt> Reserved<E> {
217	/// The resolved track name, available before the layout is.
218	pub(crate) fn name(&self) -> &str {
219		&self.name
220	}
221
222	/// The underlying track producer, e.g. to watch subscriber state.
223	pub(crate) fn track(&self) -> &moq_net::track::Producer {
224		self.track.track()
225	}
226
227	/// Finalize a track that never got a rendition.
228	pub(crate) fn finish(mut self) -> Result<(), Error> {
229		self.track.finish()?;
230		Ok(())
231	}
232
233	/// Abort a track that never got a rendition, so subscribers see `err`.
234	pub(crate) fn abort(self, err: moq_net::Error) {
235		self.track.abort(err);
236	}
237}
238
239impl<E: CatalogExt> Producer<E> {
240	/// Publish a track encoding `input` into `broadcast`, registering its
241	/// rendition in `catalog` immediately.
242	pub fn new(
243		broadcast: &mut moq_net::broadcast::Producer,
244		catalog: moq_mux::catalog::Producer<E>,
245		input: Input,
246		options: &Options,
247	) -> Result<Self, Error> {
248		let mut reserved = Reserved::new(broadcast, catalog, options)?;
249		let registered = reserved.register(input, options)?;
250		Ok(reserved.encode(registered))
251	}
252
253	/// The name of the published track, which is [`Options::track`] resolved.
254	pub fn track_name(&self) -> &str {
255		&self.rendition.name
256	}
257
258	/// The underlying track producer, e.g. to watch subscriber state via
259	/// [`used`](moq_net::track::Producer::used) / [`unused`](moq_net::track::Producer::unused).
260	pub fn track(&self) -> &moq_net::track::Producer {
261		self.track.track()
262	}
263
264	/// Whether the packet published most recently coded audio, or withheld it
265	/// because the input was silent.
266	///
267	/// A local "am I talking" indicator without running a second voice detector
268	/// over the microphone, though a silent run is punctuated by coded frames
269	/// that read [`Activity::Active`], so hold the indicator across those rather
270	/// than following it packet by packet. [`Activity::Active`] until the first
271	/// packet, and for codecs without a discontinuous mode.
272	pub fn activity(&self) -> Activity {
273		self.activity
274	}
275
276	/// Current encoder target bitrate in bits per second.
277	pub fn bitrate(&self) -> u64 {
278		self.encoder.bitrate()
279	}
280
281	/// Retune the live encoder to `bitrate` bits per second.
282	pub fn set_bitrate(&mut self, bitrate: u64) -> Result<(), Error> {
283		self.encoder.set_bitrate(bitrate)
284	}
285
286	/// Re-anchor the timeline to the next frame's timestamp, dropping any
287	/// buffered samples. Call this when resuming after an idle gap (e.g. a
288	/// released-then-reopened microphone) so the gap appears in the PTS and
289	/// audio stays aligned with a wall-clock video track, rather than the gap
290	/// being compressed out by the running sample count. Mirrors moq-boy's
291	/// `reset_epoch`. If the codec had started, an empty group is published before
292	/// the next packet so subscribers reset their decoders too.
293	pub fn reset_epoch(&mut self) {
294		if self.encoder.started() && !self.decoder_boundary {
295			self.pending_discontinuity = true;
296		}
297		self.reset_state();
298	}
299
300	fn reset_state(&mut self) {
301		self.epoch_us = None;
302		self.activity = Activity::Active;
303		self.frames_produced = 0;
304		self.pending.clear();
305		self.encoder.reset();
306		// The resampler holds samples of its own, plus filter state primed by them.
307		// Left alone, `finish` would flush that pre-reset audio onto the track
308		// (stamped at an epoch that no longer exists), and the next write would run
309		// the new audio through a filter still ringing with the old.
310		if let Some(resampler) = self.resampler.as_mut() {
311			resampler.reset();
312		}
313	}
314
315	/// Push one [`Frame`] of PCM in the layout declared by [`Input`]. Encodes and
316	/// publishes as many packets as the input contains; any partial trailing
317	/// frame is carried to the next call.
318	///
319	/// The first frame after construction (or [`reset_epoch`](Self::reset_epoch))
320	/// anchors the timeline: its timestamp becomes the epoch, and emitted PTS
321	/// then advances purely by the running sample count, so subsequent frames'
322	/// timestamps are ignored. An idle gap is only reflected in the PTS if you
323	/// call [`reset_epoch`](Self::reset_epoch) on resume (which re-anchors from
324	/// the next frame's wall-clock stamp); writing straight across a gap without
325	/// resetting compresses it out.
326	///
327	/// [`Frame::activity`] is ignored: the encoder classifies what it actually
328	/// produced, which [`activity`](Self::activity) reports.
329	pub fn write(&mut self, frame: &Frame) -> Result<(), Error> {
330		if self.pending_discontinuity {
331			self.track.discontinuity()?;
332			self.pending_discontinuity = false;
333			self.decoder_boundary = true;
334		}
335
336		let timestamp_us = u64::try_from(frame.timestamp.as_micros())
337			.map_err(|_| Error::Unsupported(format!("frame timestamp {:?} out of range", frame.timestamp)))?;
338		let epoch_us = *self.epoch_us.get_or_insert(timestamp_us);
339
340		let input = &self.encoder.config().input;
341		let (format, channels) = (input.format, input.channels);
342		let pcm = format.as_interleaved_f32(frame.data.as_ref(), channels)?;
343		let pcm: Vec<f32> = match self.resampler.as_mut() {
344			Some(r) => r.process(&pcm)?,
345			None => pcm.into_owned(),
346		};
347
348		self.pending.extend(pcm);
349
350		self.publish_full_frames(epoch_us)
351	}
352
353	/// Encode and publish every full frame in `pending`, keeping any partial
354	/// trailing frame for the next call.
355	fn publish_full_frames(&mut self, epoch_us: u64) -> Result<(), Error> {
356		let frame_samples = self.encoder.frame_size() * self.encoder.codec_channels() as usize;
357		while self.pending.len() >= frame_samples {
358			let chunk: Vec<f32> = self.pending.drain(..frame_samples).collect();
359			let packet = self.encoder.encode(&chunk)?;
360
361			let timestamp = Self::timestamp(epoch_us, self.frames_produced, self.encoder.codec_rate())?;
362			self.frames_produced += self.encoder.frame_size() as u64;
363			self.activity = packet.activity;
364			Self::publish(&mut self.track, packet, timestamp)?;
365			self.decoder_boundary = false;
366		}
367
368		Ok(())
369	}
370
371	/// PTS of the next frame: the epoch plus the samples emitted since it.
372	fn timestamp(epoch_us: u64, frames_produced: u64, codec_rate: u32) -> Result<Timestamp, Error> {
373		let offset_us = (frames_produced * 1_000_000) / codec_rate as u64;
374		Ok(Timestamp::from_micros(epoch_us + offset_us)?)
375	}
376
377	fn publish(
378		track: &mut moq_mux::container::Producer<moq_mux::container::legacy::Wire>,
379		encoded: Encoded,
380		timestamp: Timestamp,
381	) -> Result<(), Error> {
382		// Publish each audio packet as its own moq-lite group: write it as a keyframe, then cut
383		// (below) so the relay forwards it without waiting for the next. Codecs can recover
384		// independently after a dropped group.
385		let mux_frame = MuxFrame {
386			timestamp,
387			payload: encoded.payload,
388			keyframe: true,
389			duration: None,
390		};
391		track.write(mux_frame)?;
392		// No boundary to give: the next packet bounds this one, and Opus frames have a
393		// deterministic duration anyway.
394		track.cut(None)?;
395		Ok(())
396	}
397
398	/// Publish terminal packets after an empty frame that carries their logical endpoint.
399	fn publish_terminal(
400		track: &mut moq_mux::container::Producer<moq_mux::container::legacy::Wire>,
401		terminal: Terminal,
402	) -> Result<(), Error> {
403		track.write(MuxFrame {
404			timestamp: terminal.end,
405			payload: Bytes::new(),
406			keyframe: true,
407			duration: None,
408		})?;
409
410		for (index, packet) in terminal.packets.into_iter().enumerate() {
411			let offset = Timestamp::from_scale((index * terminal.frame_size) as u64, terminal.codec_rate as u64)?
412				.convert(terminal.start.scale())?;
413			track.write(MuxFrame {
414				timestamp: terminal.start.checked_add(offset)?,
415				payload: packet.payload,
416				keyframe: false,
417				duration: None,
418			})?;
419		}
420
421		track.cut(Some(terminal.end))?;
422		Ok(())
423	}
424
425	/// Mark a break in the published timeline and reset codec state.
426	///
427	/// Call this when capture stops rather than merely gapping between packets: going idle,
428	/// switching source, or anything else that resumes on a re-anchored epoch. Buffered samples
429	/// are dropped, and the next frame anchors a fresh codec epoch. See
430	/// [`Producer::discontinuity`](moq_mux::container::Producer::discontinuity).
431	pub fn discontinuity(&mut self) -> Result<(), Error> {
432		self.track.discontinuity()?;
433		self.pending_discontinuity = false;
434		self.decoder_boundary = true;
435		self.reset_state();
436		Ok(())
437	}
438
439	/// Flush pending samples, resampler output, and codec lookahead, then finalize
440	/// the track.
441	pub fn finish(mut self) -> Result<(), Error> {
442		// Whatever the resampler still holds belongs to this track: its last partial
443		// chunk, plus the audio its filter is running behind on. Dropping it here
444		// would publish a track that ends before its source did.
445		if let Some(resampler) = self.resampler.take() {
446			self.pending.extend(resampler.flush()?);
447		}
448
449		// The drained resampler tail can span multiple frames. Publish those first
450		// so only the final partial frame reaches the encoder's terminal drain.
451		let epoch_us = self.epoch_us.unwrap_or(0);
452		self.publish_full_frames(epoch_us)?;
453
454		let frame_size = self.encoder.frame_size();
455		let codec_rate = self.encoder.codec_rate();
456		let channels = self.encoder.codec_channels() as usize;
457		let source_frames = self.pending.len() / channels;
458		let start = Self::timestamp(epoch_us, self.frames_produced, codec_rate)?;
459		let end = Self::timestamp(epoch_us, self.frames_produced + source_frames as u64, codec_rate)?;
460		let finish = self.encoder.finish(&self.pending)?;
461		let discard_padding = finish.discard_padding();
462		let packets = finish.into_packets();
463
464		if discard_padding > 0 {
465			Self::publish_terminal(
466				&mut self.track,
467				Terminal {
468					packets,
469					end,
470					start,
471					frame_size,
472					codec_rate,
473				},
474			)?;
475		} else {
476			for packet in packets {
477				let timestamp = Self::timestamp(epoch_us, self.frames_produced, codec_rate)?;
478				self.activity = packet.activity;
479				Self::publish(&mut self.track, packet, timestamp)?;
480				self.frames_produced += frame_size as u64;
481			}
482		}
483
484		self.track.finish()?;
485		Ok(())
486	}
487
488	/// Abort the track with `err` instead of finishing it, so subscribers see the
489	/// real cause rather than [`moq_net::Error::Dropped`]. Pending samples are dropped.
490	pub fn abort(self, err: moq_net::Error) {
491		self.track.abort(err);
492	}
493}
494
495/// The producer's catalog entry, removed however the producer ends.
496///
497/// A separate value rather than a `Drop` on [`Producer`] itself, so the terminal
498/// [`finish`](Producer::finish) / [`abort`](Producer::abort) can consume the track.
499struct Rendition<E: CatalogExt> {
500	catalog: moq_mux::catalog::Producer<E>,
501	name: String,
502}
503
504impl<E: CatalogExt> Drop for Rendition<E> {
505	fn drop(&mut self) {
506		self.catalog.lock().audio.remove(&self.name);
507	}
508}
509
510#[cfg(test)]
511mod tests {
512	use super::*;
513	use crate::decode::{Config as DecodeConfig, Consumer as AudioConsumer};
514	use crate::{Activity, Format};
515
516	/// Terminal Opus lookahead samples survive both exact-frame and partial-frame input.
517	#[tokio::test]
518	async fn finish_publishes_the_opus_lookahead_tail() {
519		for frames in [960, 860] {
520			let input = Input {
521				format: Format::F32,
522				sample_rate: 48_000,
523				channels: 1,
524			};
525			let options = Options {
526				track: Some("audio".to_string()),
527				bitrate: Some(128_000),
528				..Options::default()
529			};
530			let decoder_config = Encoder::new(&options.config(input.clone())).unwrap().catalog();
531
532			let mut broadcast = moq_net::broadcast::Info::new().produce();
533			let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
534			let consumer = broadcast.consume();
535			let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap();
536			let mut audio = AudioConsumer::new(&consumer, &decoder_config, "audio", DecodeConfig::new())
537				.await
538				.unwrap();
539
540			let mut pcm = vec![0.0f32; frames];
541			let impulse = pcm.len() - 100;
542			pcm[impulse] = 1.0;
543			let data: Vec<u8> = pcm.iter().flat_map(|sample| sample.to_le_bytes()).collect();
544			producer.write(&Frame::new(Bytes::from(data), Timestamp::ZERO)).unwrap();
545			producer.finish().unwrap();
546
547			let mut decoded = Vec::new();
548			while let Some(frame) = audio.read().await.unwrap() {
549				let pcm = Format::F32.as_interleaved_f32(&frame.data, 1).unwrap();
550				decoded.extend_from_slice(&pcm);
551			}
552			assert_eq!(decoded.len(), frames, "terminal padding extended the source");
553			let peak = decoded.iter().fold(0.0f32, |peak, sample| peak.max(sample.abs()));
554			assert!(peak > 0.1, "the {frames}-frame Opus tail lost the impulse: peak {peak}");
555		}
556	}
557
558	/// A resampled publisher used to end its track early: `finish` flushed the
559	/// encoder's own buffer but left the resampler holding its last partial chunk,
560	/// plus the audio its filter runs behind on.
561	#[tokio::test]
562	async fn finish_publishes_the_resampled_tail() {
563		let input = Input {
564			format: Format::F32,
565			sample_rate: 44_100,
566			channels: 1,
567		};
568
569		let mut broadcast = moq_net::broadcast::Info::new().produce();
570		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
571		let consumer = broadcast.consume();
572		let options = Options {
573			track: Some("audio".to_string()),
574			..Options::default()
575		};
576		let mut producer = Producer::new(&mut broadcast, catalog, input.clone(), &options).unwrap();
577
578		// Subscribe before the track ends, or there is nothing left to subscribe to.
579		let mut track = moq_mux::container::Consumer::new(
580			consumer
581				.track("audio")
582				.unwrap()
583				.subscribe(moq_net::track::Subscription::default())
584				.await
585				.unwrap(),
586			moq_mux::catalog::hang::Container::Legacy,
587		);
588
589		// Chosen so the tail decides a whole packet: 8838 frames at 44.1 kHz is ~9620
590		// at 48 kHz, just past ten 960-sample Opus frames. Losing the resampler's
591		// remainder and its filter delay drops back under ten, costing a packet.
592		let data: Vec<u8> = vec![0.25f32; 8_838].iter().flat_map(|s| s.to_le_bytes()).collect();
593		producer
594			.write(&Frame::new(data.into(), moq_net::Timestamp::ZERO))
595			.unwrap();
596		producer.finish().unwrap();
597
598		let mut packets = 0;
599		while track.read().await.unwrap().is_some() {
600			packets += 1;
601		}
602		assert_eq!(packets, 11);
603	}
604
605	/// `reset_epoch` promises to drop buffered samples, and the resampler buffers
606	/// samples of its own. Leaving those behind let `finish` flush pre-reset audio
607	/// onto the track, stamped at an epoch that no longer exists.
608	#[tokio::test]
609	async fn reset_epoch_drops_the_resampler_buffer_too() {
610		let input = Input {
611			format: Format::F32,
612			sample_rate: 44_100,
613			channels: 1,
614		};
615
616		let mut broadcast = moq_net::broadcast::Info::new().produce();
617		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
618		let consumer = broadcast.consume();
619		let options = Options {
620			track: Some("audio".to_string()),
621			..Options::default()
622		};
623		let mut producer = Producer::new(&mut broadcast, catalog, input.clone(), &options).unwrap();
624
625		let mut track = moq_mux::container::Consumer::new(
626			consumer
627				.track("audio")
628				.unwrap()
629				.subscribe(moq_net::track::Subscription::default())
630				.await
631				.unwrap(),
632			moq_mux::catalog::hang::Container::Legacy,
633		);
634
635		// Too little to publish a packet, so it all sits in the resampler.
636		let data: Vec<u8> = vec![0.25f32; 441].iter().flat_map(|s| s.to_le_bytes()).collect();
637		producer
638			.write(&Frame::new(data.into(), moq_net::Timestamp::ZERO))
639			.unwrap();
640
641		producer.reset_epoch();
642		producer.finish().unwrap();
643
644		// The reset dropped everything, so the track ends without a packet.
645		assert!(track.read().await.unwrap().is_none());
646	}
647
648	/// Resetting after a full frame drops codec lookahead as well as producer buffers.
649	#[tokio::test]
650	async fn reset_epoch_drops_the_encoder_lookahead() {
651		let mut broadcast = moq_net::broadcast::Info::new().produce();
652		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
653		let consumer = broadcast.consume();
654		let options = Options {
655			track: Some("audio".to_string()),
656			..Options::default()
657		};
658		let mut producer = Producer::new(
659			&mut broadcast,
660			catalog,
661			Input {
662				channels: 1,
663				..Input::default()
664			},
665			&options,
666		)
667		.unwrap();
668		let mut track = moq_mux::container::Consumer::new(
669			consumer
670				.track("audio")
671				.unwrap()
672				.subscribe(moq_net::track::Subscription::default())
673				.await
674				.unwrap(),
675			moq_mux::catalog::hang::Container::Legacy,
676		);
677
678		producer.write(&full_frame(1_000_000)).unwrap();
679		producer.reset_epoch();
680		producer.finish().unwrap();
681
682		assert!(track.read().await.unwrap().is_some());
683		assert!(track.read().await.unwrap().is_none());
684	}
685
686	/// A codec reset starts a new pre-skip interval at the receiver too.
687	#[tokio::test]
688	async fn reset_epoch_restarts_the_decoder() {
689		let input = Input {
690			format: Format::F32,
691			sample_rate: 48_000,
692			channels: 1,
693		};
694		let options = Options {
695			track: Some("audio".to_string()),
696			..Options::default()
697		};
698		let decoder_config = Encoder::new(&options.config(input.clone())).unwrap().catalog();
699
700		let mut broadcast = moq_net::broadcast::Info::new().produce();
701		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
702		let subscriber = broadcast.consume();
703		let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap();
704		let mut audio = AudioConsumer::new(
705			&subscriber,
706			&decoder_config,
707			"audio",
708			DecodeConfig {
709				latency_max: Some(Duration::from_millis(500)),
710				..DecodeConfig::new()
711			},
712		)
713		.await
714		.unwrap();
715
716		producer.write(&full_frame(0)).unwrap();
717		let first = audio.read().await.unwrap().expect("first epoch packet");
718		assert_eq!(first.data.len() / size_of::<f32>(), 960 - 312);
719
720		producer.reset_epoch();
721		producer.write(&full_frame(1_000_000)).unwrap();
722		producer.finish().unwrap();
723
724		let mut resumed_frames = 0;
725		while let Some(frame) = audio.read().await.unwrap() {
726			assert!(frame.timestamp.as_micros() >= 1_000_000);
727			resumed_frames += frame.data.len() / size_of::<f32>();
728		}
729		assert_eq!(resumed_frames, 960, "the resumed epoch must trim its own pre-skip once");
730	}
731
732	#[tokio::test]
733	async fn producer_and_consumer_keep_activity_on_the_audio_stream() {
734		let input = Input {
735			format: Format::F32,
736			sample_rate: 48_000,
737			channels: 1,
738		};
739		let options = Options {
740			track: Some("audio".to_string()),
741			bitrate: Some(24_000),
742			dtx: true,
743			..Options::default()
744		};
745		let decoder_config = Encoder::new(&options.config(input.clone())).unwrap().catalog();
746
747		let mut broadcast = moq_net::broadcast::Info::new().produce();
748		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
749		let subscriber = broadcast.consume();
750		let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap();
751		let mut consumer = AudioConsumer::new(&subscriber, &decoder_config, "audio", DecodeConfig::new())
752			.await
753			.unwrap();
754
755		let silence = vec![0.0; 960];
756		let mut entered_dtx = false;
757		for index in 0..100 {
758			producer.write(&pcm_frame(&silence, index * 20_000)).unwrap();
759			let consumed = consumer.read().await.unwrap().expect("one decoded frame");
760			assert_eq!(producer.activity(), consumed.activity);
761			if consumed.activity.is_dtx() {
762				entered_dtx = true;
763				break;
764			}
765		}
766		assert!(entered_dtx, "silence should enter Opus DTX");
767
768		let active: Vec<f32> = (0..960)
769			.map(|sample| {
770				let phase = sample as f32 * 440.0 * 2.0 * std::f32::consts::PI / 48_000.0;
771				phase.sin() * 0.5
772			})
773			.collect();
774		producer.write(&pcm_frame(&active, 2_000_000)).unwrap();
775		let consumed = consumer.read().await.unwrap().expect("one decoded frame");
776		assert_eq!(producer.activity(), Activity::Active);
777		assert_eq!(consumed.activity, Activity::Active);
778	}
779
780	// One 20 ms Opus frame at 48 kHz mono is exactly 960 f32 samples, so each
781	// `write` of this drains precisely one packet (no resampler, no leftover).
782	fn full_frame(timestamp_us: u64) -> Frame {
783		pcm_frame(&vec![0.1; 960], timestamp_us)
784	}
785
786	fn pcm_frame(samples: &[f32], timestamp_us: u64) -> Frame {
787		let data: Vec<u8> = samples.iter().flat_map(|sample| sample.to_le_bytes()).collect();
788		Frame::new(Bytes::from(data), Timestamp::from_micros(timestamp_us).unwrap())
789	}
790
791	/// Publish each frame and read back the resulting packet PTS (microseconds).
792	/// If `reset_before` contains an index, `reset_epoch()` is called before that
793	/// frame's `write`.
794	async fn published_pts(frames: &[Frame], reset_before: Option<usize>) -> Vec<u128> {
795		let mut broadcast = moq_net::broadcast::Info::new().produce();
796		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
797		let consumer = broadcast.consume();
798
799		// Input rate == Opus codec rate, so there's no resampler and sample
800		// counts stay exact, making the PTS assertions deterministic.
801		let input = Input {
802			format: Format::F32,
803			sample_rate: 48_000,
804			channels: 1,
805		};
806		let options = Options {
807			track: Some("audio".to_string()),
808			..Options::default()
809		};
810		let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap();
811
812		let track = consumer.track("audio").unwrap().subscribe(None).await.unwrap();
813		let mut reader = moq_mux::container::Consumer::new(track, moq_mux::container::legacy::Wire);
814
815		let mut pts = Vec::new();
816		for (i, frame) in frames.iter().enumerate() {
817			if reset_before == Some(i) {
818				producer.reset_epoch();
819			}
820			producer.write(frame).unwrap();
821			let read = reader.read().await.unwrap().expect("a packet per full frame");
822			pts.push(read.timestamp.as_micros());
823		}
824		pts
825	}
826
827	#[tokio::test]
828	async fn epoch_anchors_to_first_frame_timestamp() {
829		// The first frame's timestamp becomes the epoch (regression guard: the
830		// old code derived PTS purely from the sample count, always near 0).
831		let pts = published_pts(&[full_frame(1_000_000)], None).await;
832		assert_eq!(pts, vec![1_000_000]);
833	}
834
835	#[tokio::test]
836	async fn pts_advances_by_frame_duration_ignoring_later_timestamps() {
837		// Second frame's own timestamp (way ahead) is ignored; PTS advances by
838		// exactly one 20 ms frame from the epoch.
839		let pts = published_pts(&[full_frame(1_000), full_frame(999_999)], None).await;
840		assert_eq!(pts, vec![1_000, 1_000 + 20_000]);
841	}
842
843	#[tokio::test]
844	async fn reset_epoch_reanchors_so_the_gap_lands_in_pts() {
845		// Frame at t=0, then reset_epoch + a frame at t=5s: the 5 s idle gap must
846		// appear in the PTS (otherwise audio drifts behind a wall-clock video track).
847		let pts = published_pts(&[full_frame(0), full_frame(5_000_000)], Some(1)).await;
848		assert_eq!(pts, vec![0, 5_000_000]);
849	}
850
851	/// `Options::track = None` derives a codec-suffixed name rather than making
852	/// the caller invent one, mirroring the video side. Pins the exact name the
853	/// docs promise, and that a second producer doesn't collide with the first.
854	#[tokio::test]
855	async fn default_options_derive_the_track_name() {
856		let mut broadcast = moq_net::broadcast::Info::new().produce();
857		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
858
859		let first = Producer::new(&mut broadcast, catalog.clone(), Input::default(), &Options::default()).unwrap();
860		assert_eq!(first.track_name(), "0.opus");
861
862		let second = Producer::new(&mut broadcast, catalog, Input::default(), &Options::default()).unwrap();
863		assert_eq!(second.track_name(), "1.opus");
864	}
865}