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 the codec pick.
39	pub bitrate: Option<u32>,
40	/// Encoded frame duration. Opus accepts 2.5 / 5 / 10 / 20 / 40 / 60 ms.
41	pub frame_duration: Duration,
42}
43
44impl Default for Options {
45	fn default() -> Self {
46		Self {
47			track: None,
48			codec: Codec::default(),
49			sample_rate: None,
50			channels: None,
51			bitrate: None,
52			frame_duration: Duration::from_millis(20),
53		}
54	}
55}
56
57impl Options {
58	/// The [`Config`] these options describe once `input`'s layout is known.
59	fn config(&self, input: Input) -> Config {
60		Config {
61			input,
62			codec: self.codec,
63			sample_rate: self.sample_rate,
64			channels: self.channels,
65			bitrate: self.bitrate,
66			frame_duration: self.frame_duration,
67		}
68	}
69}
70
71/// Encode raw PCM and publish it as a moq-mux audio track.
72///
73/// The input PCM layout is fixed at construction via [`Input`]; the codec
74/// settings via [`Options`]. Subsequent [`write`](Self::write) calls just pass a
75/// [`Frame`]: payload bytes and a timestamp.
76///
77/// The catalog rendition is registered at construction (not on first write), so
78/// a subscriber that opens the catalog before any frames arrive still sees the
79/// track.
80pub struct Producer<E: CatalogExt = ()> {
81	encoder: Encoder,
82	resampler: Option<Resampler>,
83	track: moq_mux::container::Producer<moq_mux::container::legacy::Wire>,
84	/// Owns the catalog rendition, retiring it when this producer goes away.
85	rendition: Rendition<E>,
86	pending: Vec<f32>,
87	/// Samples emitted since the current epoch (reset by [`reset_epoch`](Self::reset_epoch)).
88	frames_produced: u64,
89	/// Wall-clock anchor in microseconds, taken from the first frame after each
90	/// (re)start. Emitted PTS = `epoch + frames_produced / codec_rate`. `None`
91	/// until the first write so the next frame re-anchors to its timestamp.
92	epoch_us: Option<u64>,
93}
94
95impl<E: CatalogExt> Producer<E> {
96	/// Publish a track encoding `input` into `broadcast`, registering its
97	/// rendition in `catalog` immediately.
98	pub fn new(
99		broadcast: &mut moq_net::broadcast::Producer,
100		catalog: moq_mux::catalog::Producer<E>,
101		input: Input,
102		options: &Options,
103	) -> Result<Self, Error> {
104		let encoder = Encoder::new(&options.config(input))?;
105		let input = &encoder.config().input;
106
107		let resampler = if input.sample_rate == encoder.codec_rate() {
108			None
109		} else {
110			// Use microsecond precision so 2.5 ms frame_duration (supported by
111			// libopus) doesn't truncate to 2 ms.
112			let chunk_frames =
113				((input.sample_rate as u128 * encoder.config().frame_duration.as_micros()) / 1_000_000) as usize;
114			Some(Resampler::new(
115				input.sample_rate,
116				encoder.codec_rate(),
117				input.channels,
118				chunk_frames,
119			)?)
120		};
121
122		let track = match &options.track {
123			// Audio hang frames carry microsecond timestamps; advertise that on the
124			// track so Lite05 subscribers know what scale to expect and the model
125			// layer accepts Frame::timestamp on append. `unique_track` does the same.
126			Some(name) => broadcast.create_track(name.clone(), hang::container::track_info())?,
127			// Mirrors the video side, which derives a unique name from the codec
128			// rather than making every caller invent one.
129			None => moq_mux::import::unique_track(broadcast, &format!(".{}", options.codec))?,
130		};
131		let name = track.name().to_string();
132		let track = catalog.media_producer(track, moq_mux::container::legacy::Wire)?;
133
134		let mut catalog_mut = catalog.clone();
135		let mut config = encoder.catalog();
136		config.timeline = Some(catalog.timeline(&name)?.section());
137		catalog_mut.lock().audio.insert(&name, config)?;
138
139		Ok(Self {
140			encoder,
141			resampler,
142			track,
143			rendition: Rendition { catalog, name },
144			pending: Vec::new(),
145			frames_produced: 0,
146			epoch_us: None,
147		})
148	}
149
150	/// The name of the published track, which is [`Options::track`] resolved.
151	pub fn track_name(&self) -> &str {
152		&self.rendition.name
153	}
154
155	/// The underlying track producer, e.g. to watch subscriber state via
156	/// [`used`](moq_net::track::Producer::used) / [`unused`](moq_net::track::Producer::unused).
157	pub fn track(&self) -> &moq_net::track::Producer {
158		self.track.track()
159	}
160
161	/// Re-anchor the timeline to the next frame's timestamp, dropping any
162	/// buffered samples. Call this when resuming after an idle gap (e.g. a
163	/// released-then-reopened microphone) so the gap appears in the PTS and
164	/// audio stays aligned with a wall-clock video track, rather than the gap
165	/// being compressed out by the running sample count. Mirrors moq-boy's
166	/// `reset_epoch`.
167	pub fn reset_epoch(&mut self) {
168		self.epoch_us = None;
169		self.frames_produced = 0;
170		self.pending.clear();
171	}
172
173	/// Push one [`Frame`] of PCM in the layout declared by [`Input`]. Encodes and
174	/// publishes as many packets as the input contains; any partial trailing
175	/// frame is carried to the next call.
176	///
177	/// The first frame after construction (or [`reset_epoch`](Self::reset_epoch))
178	/// anchors the timeline: its timestamp becomes the epoch, and emitted PTS
179	/// then advances purely by the running sample count, so subsequent frames'
180	/// timestamps are ignored. An idle gap is only reflected in the PTS if you
181	/// call [`reset_epoch`](Self::reset_epoch) on resume (which re-anchors from
182	/// the next frame's wall-clock stamp); writing straight across a gap without
183	/// resetting compresses it out.
184	pub fn write(&mut self, frame: &Frame) -> Result<(), Error> {
185		let timestamp_us = u64::try_from(frame.timestamp.as_micros())
186			.map_err(|_| Error::Unsupported(format!("frame timestamp {:?} out of range", frame.timestamp)))?;
187		let epoch_us = *self.epoch_us.get_or_insert(timestamp_us);
188
189		let input = &self.encoder.config().input;
190		let (format, channels) = (input.format, input.channels);
191		let pcm = format.as_interleaved_f32(frame.data.as_ref(), channels)?;
192		let pcm: Vec<f32> = match self.resampler.as_mut() {
193			Some(r) => r.process(&pcm)?,
194			None => pcm.into_owned(),
195		};
196
197		self.pending.extend(pcm);
198
199		let frame_samples = self.encoder.frame_size() * self.encoder.codec_channels() as usize;
200		while self.pending.len() >= frame_samples {
201			let chunk: Vec<f32> = self.pending.drain(..frame_samples).collect();
202			let packet = self.encoder.encode(&chunk)?;
203
204			let timestamp = self.timestamp(epoch_us)?;
205			self.frames_produced += self.encoder.frame_size() as u64;
206			self.publish(packet, timestamp)?;
207		}
208
209		Ok(())
210	}
211
212	/// PTS of the next frame: the epoch plus the samples emitted since it.
213	fn timestamp(&self, epoch_us: u64) -> Result<Timestamp, Error> {
214		let offset_us = (self.frames_produced * 1_000_000) / self.encoder.codec_rate() as u64;
215		Ok(Timestamp::from_micros(epoch_us + offset_us)?)
216	}
217
218	fn publish(&mut self, payload: Bytes, timestamp: Timestamp) -> Result<(), Error> {
219		// Each audio packet is its own moq-lite group, matching
220		// moq_mux::codec::opus::Import. Opus PLC handles dropped groups.
221		let mux_frame = MuxFrame {
222			timestamp,
223			payload,
224			keyframe: true,
225			duration: None,
226		};
227		self.track.write(mux_frame)?;
228		// No boundary to give: the next packet bounds this one, and Opus frames have a
229		// deterministic duration anyway.
230		self.track.cut(None)?;
231		Ok(())
232	}
233
234	/// Mark a break in the published timeline: whatever is published next does not continue
235	/// what came before.
236	///
237	/// Call this when capture stops rather than merely gapping between packets -- going idle,
238	/// switching source, anything that resumes on a re-anchored epoch (see
239	/// [`reset_epoch`](Self::reset_epoch)). See
240	/// [`Producer::discontinuity`](moq_mux::container::Producer::discontinuity).
241	pub fn discontinuity(&mut self) -> Result<(), Error> {
242		self.track.discontinuity()?;
243		Ok(())
244	}
245
246	/// Flush any pending samples (zero-padded to a full frame) and finalize the
247	/// track.
248	pub fn finish(mut self) -> Result<(), Error> {
249		let frame_samples = self.encoder.frame_size() * self.encoder.codec_channels() as usize;
250		if !self.pending.is_empty() {
251			self.pending.resize(frame_samples, 0.0);
252			let chunk = std::mem::take(&mut self.pending);
253			let packet = self.encoder.encode(&chunk)?;
254			let timestamp = self.timestamp(self.epoch_us.unwrap_or(0))?;
255			self.publish(packet, timestamp)?;
256		}
257		self.track.finish()?;
258		Ok(())
259	}
260
261	/// Abort the track with `err` instead of finishing it, so subscribers see the
262	/// real cause rather than [`moq_net::Error::Dropped`]. Pending samples are dropped.
263	pub fn abort(self, err: moq_net::Error) {
264		self.track.abort(err);
265	}
266}
267
268/// The producer's catalog entry, removed however the producer ends.
269///
270/// A separate value rather than a `Drop` on [`Producer`] itself, so the terminal
271/// [`finish`](Producer::finish) / [`abort`](Producer::abort) can consume the track.
272struct Rendition<E: CatalogExt> {
273	catalog: moq_mux::catalog::Producer<E>,
274	name: String,
275}
276
277impl<E: CatalogExt> Drop for Rendition<E> {
278	fn drop(&mut self) {
279		self.catalog.lock().audio.remove(&self.name);
280	}
281}
282
283#[cfg(test)]
284mod tests {
285	use super::*;
286	use crate::Format;
287
288	// One 20 ms Opus frame at 48 kHz mono is exactly 960 f32 samples, so each
289	// `write` of this drains precisely one packet (no resampler, no leftover).
290	fn full_frame(timestamp_us: u64) -> Frame {
291		let mut data = Vec::with_capacity(960 * 4);
292		for _ in 0..960 {
293			data.extend_from_slice(&0.1f32.to_le_bytes());
294		}
295		Frame {
296			timestamp: Timestamp::from_micros(timestamp_us).unwrap(),
297			data: data.into(),
298		}
299	}
300
301	/// Publish each frame and read back the resulting packet PTS (microseconds).
302	/// If `reset_before` contains an index, `reset_epoch()` is called before that
303	/// frame's `write`.
304	async fn published_pts(frames: &[Frame], reset_before: Option<usize>) -> Vec<u128> {
305		let mut broadcast = moq_net::broadcast::Info::new().produce();
306		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
307		let consumer = broadcast.consume();
308
309		// Input rate == Opus codec rate, so there's no resampler and sample
310		// counts stay exact, making the PTS assertions deterministic.
311		let input = Input {
312			format: Format::F32,
313			sample_rate: 48_000,
314			channels: 1,
315		};
316		let options = Options {
317			track: Some("audio".to_string()),
318			..Options::default()
319		};
320		let mut producer = Producer::new(&mut broadcast, catalog, input, &options).unwrap();
321
322		let track = consumer.track("audio").unwrap().subscribe(None).await.unwrap();
323		let mut reader = moq_mux::container::Consumer::new(track, moq_mux::container::legacy::Wire);
324
325		let mut pts = Vec::new();
326		for (i, frame) in frames.iter().enumerate() {
327			if reset_before == Some(i) {
328				producer.reset_epoch();
329			}
330			producer.write(frame).unwrap();
331			let read = reader.read().await.unwrap().expect("a packet per full frame");
332			pts.push(read.timestamp.as_micros());
333		}
334		pts
335	}
336
337	#[tokio::test]
338	async fn epoch_anchors_to_first_frame_timestamp() {
339		// The first frame's timestamp becomes the epoch (regression guard: the
340		// old code derived PTS purely from the sample count, always near 0).
341		let pts = published_pts(&[full_frame(1_000_000)], None).await;
342		assert_eq!(pts, vec![1_000_000]);
343	}
344
345	#[tokio::test]
346	async fn pts_advances_by_frame_duration_ignoring_later_timestamps() {
347		// Second frame's own timestamp (way ahead) is ignored; PTS advances by
348		// exactly one 20 ms frame from the epoch.
349		let pts = published_pts(&[full_frame(1_000), full_frame(999_999)], None).await;
350		assert_eq!(pts, vec![1_000, 1_000 + 20_000]);
351	}
352
353	#[tokio::test]
354	async fn reset_epoch_reanchors_so_the_gap_lands_in_pts() {
355		// Frame at t=0, then reset_epoch + a frame at t=5s: the 5 s idle gap must
356		// appear in the PTS (otherwise audio drifts behind a wall-clock video track).
357		let pts = published_pts(&[full_frame(0), full_frame(5_000_000)], Some(1)).await;
358		assert_eq!(pts, vec![0, 5_000_000]);
359	}
360
361	/// `Options::track = None` derives a codec-suffixed name rather than making
362	/// the caller invent one, mirroring the video side. Pins the exact name the
363	/// docs promise, and that a second producer doesn't collide with the first.
364	#[tokio::test]
365	async fn default_options_derive_the_track_name() {
366		let mut broadcast = moq_net::broadcast::Info::new().produce();
367		let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
368
369		let first = Producer::new(&mut broadcast, catalog.clone(), Input::default(), &Options::default()).unwrap();
370		assert_eq!(first.track_name(), "0.opus");
371
372		let second = Producer::new(&mut broadcast, catalog, Input::default(), &Options::default()).unwrap();
373		assert_eq!(second.track_name(), "1.opus");
374	}
375}