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