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