Skip to main content

moq/
audio.rs

1//! Raw-audio import/export via [`moq_audio`].
2//!
3//! Sibling to `moq_publish_media_*` / `moq_consume_audio`
4//! (those handle already-encoded frames). These functions accept and
5//! return raw PCM, with Opus encode/decode happening inside the FFI
6//! boundary.
7//!
8//! Format / sample rate / channel count are fixed at producer or
9//! consumer construction via [`moq_audio_encoder_input`] /
10//! [`moq_audio_encoder_output`] / [`moq_audio_decoder_output`], so
11//! each [`moq_audio_frame`] carries only payload bytes and a
12//! timestamp.
13
14use std::ffi::{c_char, c_void};
15use std::time::Duration;
16
17use bytes::Bytes;
18use tokio::sync::oneshot;
19
20use crate::ffi::OnStatus;
21use crate::{Error, Id, NonZeroSlab, Shared, State, ffi};
22
23// ---- C-visible types ----
24
25/// Raw PCM sample layout, mirroring WebCodecs `AudioData.format`.
26///
27/// The enum is exposed in the C header for readability, but ABI
28/// fields/parameters that carry it are typed `u32`. A C caller
29/// passing an unknown discriminant gets `Error::InvalidCode` instead
30/// of UB.
31///
32/// <https://developer.mozilla.org/en-US/docs/Web/API/AudioData/format>
33#[repr(C)]
34#[allow(non_camel_case_types)]
35#[derive(Clone, Copy, Debug)]
36pub enum moq_audio_format {
37	MOQ_AUDIO_FORMAT_U8 = 0,
38	MOQ_AUDIO_FORMAT_S16 = 1,
39	MOQ_AUDIO_FORMAT_S32 = 2,
40	MOQ_AUDIO_FORMAT_F32 = 3,
41	MOQ_AUDIO_FORMAT_U8_PLANAR = 4,
42	MOQ_AUDIO_FORMAT_S16_PLANAR = 5,
43	MOQ_AUDIO_FORMAT_S32_PLANAR = 6,
44	MOQ_AUDIO_FORMAT_F32_PLANAR = 7,
45}
46
47fn audio_format_from_u32(value: u32) -> Result<moq_audio::Format, Error> {
48	use moq_audio::Format;
49	Ok(match value {
50		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_U8 as u32 => Format::U8,
51		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_S16 as u32 => Format::S16,
52		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_S32 as u32 => Format::S32,
53		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_F32 as u32 => Format::F32,
54		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_U8_PLANAR as u32 => Format::U8Planar,
55		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_S16_PLANAR as u32 => Format::S16Planar,
56		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_S32_PLANAR as u32 => Format::S32Planar,
57		v if v == moq_audio_format::MOQ_AUDIO_FORMAT_F32_PLANAR as u32 => Format::F32Planar,
58		_ => return Err(Error::InvalidCode),
59	})
60}
61
62/// PCM layout the caller hands to [`moq_publish_audio_raw_frame`].
63#[repr(C)]
64#[allow(non_camel_case_types)]
65pub struct moq_audio_encoder_input {
66	/// `moq_audio_format` discriminant.
67	pub format: u32,
68	pub sample_rate: u32,
69	pub channels: u32,
70}
71
72/// Codec-side configuration. `sample_rate` / `channels` = 0 means
73/// "match the input (snapping the rate up to a libopus-supported
74/// value if necessary)".
75#[repr(C)]
76#[allow(non_camel_case_types)]
77pub struct moq_audio_encoder_output {
78	/// Codec id, UTF-8 (currently only "opus").
79	pub codec: *const c_char,
80	pub codec_len: usize,
81	/// 0 = derive from input.
82	pub sample_rate: u32,
83	/// 0 = derive from input.
84	pub channels: u32,
85	/// 0 = libopus default.
86	pub bitrate: u32,
87	/// Encoded frame duration in milliseconds. Opus accepts
88	/// 2.5/5/10/20/40/60 ms; pass 20 to match the JS publish path.
89	/// (For 2.5 ms, the caller must pre-round; integer ms only.)
90	pub frame_duration_ms: u32,
91}
92
93/// PCM layout the caller wants out of [`moq_consume_audio_raw`].
94#[repr(C)]
95#[allow(non_camel_case_types)]
96pub struct moq_audio_decoder_output {
97	pub format: u32,
98	/// 0 = deliver at the codec's native sample rate.
99	pub sample_rate: u32,
100	/// 0 = deliver at the codec's native channel count.
101	pub channels: u32,
102	/// Upper bound on buffering before skipping a stalled group, in
103	/// milliseconds. Same congestion-control knob as
104	/// `moq_consume_audio`'s `max_latency_ms`. 0 = skip
105	/// aggressively (the moq-mux default); set to your playout
106	/// buffer (tens to a few hundred ms) for a softer skip. Named
107	/// `_max` to leave room for a future `latency_min_ms`
108	/// (jitter-buffer floor).
109	pub latency_max_ms: u64,
110}
111
112/// One audio frame: payload bytes plus a presentation timestamp.
113///
114/// `data` is owned by the consume slab (see
115/// [`moq_consume_audio_raw_frame_free`]) or borrowed by the publish call
116/// (the publisher copies before returning).
117#[repr(C)]
118#[allow(non_camel_case_types)]
119pub struct moq_audio_frame {
120	pub timestamp_us: u64,
121	pub data: *const u8,
122	pub data_size: usize,
123}
124
125// ---- State extensions (used internally by lib.rs) ----
126
127/// An audio producer, shared so the Opus encode in `write` runs with the global
128/// lock released. See [`Shared`].
129type AudioProducer = Shared<moq_audio::encode::Producer<moq_mux::catalog::hang::Extra>>;
130
131#[derive(Default)]
132pub struct Audio {
133	producers: NonZeroSlab<AudioProducer>,
134	consumer_tasks: NonZeroSlab<Option<AudioTaskEntry>>,
135	frames: NonZeroSlab<moq_audio::Frame>,
136}
137
138/// A spawned task entry: `close` signals shutdown, `callback` delivers status.
139///
140/// `close` is an `Option` so `consume_close` can drop just the sender without
141/// removing the entry. The task delivers one final terminal callback and then
142/// removes itself, so `user_data` stays valid until that callback fires.
143struct AudioTaskEntry {
144	close: Option<oneshot::Sender<()>>,
145	callback: OnStatus,
146}
147
148impl Audio {
149	pub fn publish(
150		&mut self,
151		broadcast: &mut moq_net::broadcast::Producer,
152		catalog: moq_mux::catalog::Producer<moq_mux::catalog::hang::Extra>,
153		input: moq_audio::encode::Input,
154		options: moq_audio::encode::Options,
155	) -> Result<Id, Error> {
156		let producer = moq_audio::encode::Producer::new(broadcast, catalog, input, &options)?;
157		self.producers.insert(Shared::new(producer))
158	}
159
160	/// Resolve a producer handle, so the caller can encode with the global lock
161	/// released.
162	///
163	/// Bind the result before locking it: a temporary [`State`] guard lives to the
164	/// end of the statement that created it, so resolving and locking in one
165	/// expression would put the encode back under the global lock.
166	pub(crate) fn producer(&self, id: Id) -> Result<AudioProducer, Error> {
167		self.producers.get(id).cloned().ok_or(Error::MediaNotFound)
168	}
169
170	/// Resolve a producer and drop its id, so nothing can be published to it after.
171	pub(crate) fn remove(&mut self, id: Id) -> Result<AudioProducer, Error> {
172		self.producers.remove(id).ok_or(Error::MediaNotFound)
173	}
174
175	pub fn consume(
176		&mut self,
177		broadcast: &moq_net::broadcast::Consumer,
178		catalog: &hang::catalog::AudioConfig,
179		name: &str,
180		config: moq_audio::decode::Config,
181		on_frame: OnStatus,
182	) -> Result<Id, Error> {
183		let broadcast = broadcast.clone();
184		let catalog = catalog.clone();
185		let name = name.to_string();
186
187		let channel = oneshot::channel();
188		let entry = AudioTaskEntry {
189			close: Some(channel.0),
190			callback: on_frame,
191		};
192		let id = self.consumer_tasks.insert(Some(entry))?;
193
194		// `decode::Consumer::new` subscribes (blocking on SUBSCRIBE_OK), so run it
195		// inside the task to keep this entrypoint non-blocking.
196		tokio::spawn(async move {
197			let res = async move {
198				let consumer = moq_audio::decode::Consumer::new(&broadcast, &catalog, name, config).await?;
199				Self::run(on_frame, consumer, channel.1).await
200			}
201			.await;
202
203			// Deliver one final terminal callback (code <= 0), then drop the entry.
204			// Pull it out from under the lock so the callback never runs while held.
205			let entry = State::lock().audio.consumer_tasks.remove(id).flatten();
206			if let Some(entry) = entry {
207				entry.callback.call(res);
208			}
209		});
210
211		Ok(id)
212	}
213
214	async fn run(
215		callback: OnStatus,
216		mut consumer: moq_audio::decode::Consumer,
217		mut close: oneshot::Receiver<()>,
218	) -> Result<(), Error> {
219		loop {
220			// `biased` so a pending close always wins over a ready frame.
221			let frame = tokio::select! {
222				biased;
223				_ = &mut close => return Ok(()),
224				frame = consumer.read() => match frame {
225					Ok(Some(frame)) => frame,
226					Ok(None) => return Ok(()),
227					// One packet the codec rejected is that packet's problem: the
228					// decoder stays usable, so drop it and keep the subscription rather
229					// than ending the caller's stream over a single bad frame.
230					Err(moq_audio::Error::Decode(err)) => {
231						tracing::warn!(%err, "dropping an audio frame");
232						continue;
233					}
234					Err(err) => return Err(err.into()),
235				},
236			};
237
238			// Hold the lock only to buffer the frame; release it before the callback.
239			let frame_id = State::lock().audio.frames.insert(frame)?;
240			callback.call(Ok(frame_id));
241		}
242	}
243
244	pub fn consume_close(&mut self, id: Id) -> Result<(), Error> {
245		// Signal shutdown; the task delivers a final callback and removes itself.
246		self.consumer_tasks
247			.get_mut(id)
248			.and_then(|entry| entry.as_mut())
249			.ok_or(Error::TrackNotFound)?
250			.close
251			.take()
252			.ok_or(Error::TrackNotFound)?;
253		Ok(())
254	}
255
256	pub fn frame_info(&self, id: Id, dst: &mut moq_audio_frame) -> Result<(), Error> {
257		let frame = self.frames.get(id).ok_or(Error::FrameNotFound)?;
258		*dst = moq_audio_frame {
259			// The C ABI carries plain microseconds, so flatten the scaled
260			// `Timestamp` here at the boundary. Saturating rather than erroring:
261			// this is a getter on a frame we already decoded, and a u64 overflow
262			// needs a timestamp ~580,000 years out.
263			timestamp_us: u64::try_from(frame.timestamp.as_micros()).unwrap_or(u64::MAX),
264			data: frame.data.as_ptr(),
265			data_size: frame.data.len(),
266		};
267		Ok(())
268	}
269
270	pub fn frame_free(&mut self, id: Id) -> Result<(), Error> {
271		self.frames.remove(id).ok_or(Error::FrameNotFound)?;
272		Ok(())
273	}
274}
275
276// ---- C entry points ----
277
278/// Open an audio track on a broadcast.
279///
280/// The encoder configuration is fixed at construction; subsequent
281/// frame writes pass only payload + timestamp via
282/// [`moq_publish_audio_raw_frame`].
283///
284/// Returns a non-zero handle on success or a negative error code.
285///
286/// # Safety
287/// - `name` must point to `name_len` bytes of UTF-8.
288/// - `input` / `output` must point to fully populated structs.
289/// - `output->codec` must point to `output->codec_len` bytes of UTF-8.
290#[unsafe(no_mangle)]
291pub unsafe extern "C" fn moq_publish_audio_raw(
292	broadcast: u32,
293	name: *const c_char,
294	name_len: usize,
295	input: *const moq_audio_encoder_input,
296	output: *const moq_audio_encoder_output,
297) -> i32 {
298	ffi::enter(move || {
299		let broadcast = ffi::parse_id(broadcast)?;
300		let name = unsafe { ffi::parse_str(name, name_len)? }.to_string();
301		let raw_input = unsafe { input.as_ref() }.ok_or(Error::InvalidPointer)?;
302		let raw_output = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
303		let codec_str = unsafe { ffi::parse_str(raw_output.codec, raw_output.codec_len)? };
304
305		let encoder_input = moq_audio::encode::Input {
306			format: audio_format_from_u32(raw_input.format)?,
307			sample_rate: raw_input.sample_rate,
308			channels: raw_input.channels,
309		};
310
311		// The C ABI takes an explicit track name and spells "unset" as 0, so map
312		// both onto the Rust options here rather than leaking either convention.
313		let mut options = moq_audio::encode::Options::default();
314		options.track = Some(name);
315		options.codec = codec_str
316			.parse()
317			.map_err(|_| Error::UnknownFormat(codec_str.to_string()))?;
318		options.sample_rate = zeroable(raw_output.sample_rate);
319		options.channels = zeroable(raw_output.channels);
320		options.bitrate = zeroable(raw_output.bitrate);
321		options.frame_duration = Duration::from_millis(raw_output.frame_duration_ms.into());
322
323		let mut state = State::lock();
324		let State { publish, audio, .. } = &mut *state;
325		let (broadcast_producer, catalog) = publish.pair_mut(broadcast)?;
326
327		audio.publish(broadcast_producer, catalog.clone(), encoder_input, options)
328	})
329}
330
331/// The C ABI spells an unset `u32` knob as 0, which no field here accepts as a
332/// real value.
333fn zeroable(value: u32) -> Option<u32> {
334	(value != 0).then_some(value)
335}
336
337/// Push one audio frame.
338///
339/// `frame->data` is borrowed for the duration of the call; the
340/// producer copies before returning.
341///
342/// # Safety
343/// - `frame` must point to a valid [`moq_audio_frame`].
344/// - `frame->data` must point to `frame->data_size` bytes.
345#[unsafe(no_mangle)]
346pub unsafe extern "C" fn moq_publish_audio_raw_frame(producer: u32, frame: *const moq_audio_frame) -> i32 {
347	ffi::enter(move || {
348		let producer = ffi::parse_id(producer)?;
349		let frame = unsafe { frame.as_ref() }.ok_or(Error::InvalidPointer)?;
350		let data = unsafe { ffi::parse_slice(frame.data, frame.data_size)? };
351
352		let owned = moq_audio::Frame {
353			// The C ABI carries plain microseconds; scale them at the boundary.
354			timestamp: moq_net::Timestamp::from_micros(frame.timestamp_us).map_err(moq_audio::Error::from)?,
355			data: Bytes::copy_from_slice(data),
356		};
357
358		let producer = State::lock().audio.producer(producer)?;
359		producer.lock().as_mut().ok_or(Error::MediaNotFound)?.write(&owned)?;
360		Ok(())
361	})
362}
363
364/// Flush any pending samples and finalize an audio producer.
365#[unsafe(no_mangle)]
366pub extern "C" fn moq_publish_audio_raw_finish(producer: u32) -> i32 {
367	ffi::enter(move || {
368		let producer = ffi::parse_id(producer)?;
369		// The id is dropped first, so nothing new queues behind the flush; whatever
370		// is mid-encode still finishes before this takes the producer.
371		let producer = State::lock().audio.remove(producer)?;
372		producer.take().ok_or(Error::MediaNotFound)?.finish()?;
373		Ok(())
374	})
375}
376
377/// Subscribe to an audio track and decode it into PCM.
378///
379/// The catalog `index` identifies which audio rendition to subscribe
380/// to, matching the existing `moq_consume_audio` selection
381/// model. TODO: a future API will pick the right rendition
382/// automatically (ABR).
383///
384/// Returns a non-zero handle on success or a negative error code.
385///
386/// `on_frame` is called with a positive frame ID per frame, then exactly once
387/// more with a terminal code: `0` (closed cleanly) or a negative error. After
388/// the terminal (`<= 0`) callback, `on_frame` is never called again and
389/// `user_data` is never touched again, so release `user_data` there. The
390/// terminal callback fires even after [`moq_consume_audio_raw_close`].
391///
392/// A packet the codec cannot decode is logged and skipped rather than ending
393/// the subscription, so a single bad frame costs that frame and not the stream.
394///
395/// # Safety
396/// - `output` must point to a valid [`moq_audio_decoder_output`].
397/// - `user_data` must stay valid until the terminal (`<= 0`) `on_frame` callback.
398#[unsafe(no_mangle)]
399pub unsafe extern "C" fn moq_consume_audio_raw(
400	catalog: u32,
401	index: u32,
402	output: *const moq_audio_decoder_output,
403	on_frame: Option<extern "C" fn(user_data: *mut c_void, frame: i32)>,
404	user_data: *mut c_void,
405) -> i32 {
406	ffi::enter(move || {
407		let catalog = ffi::parse_id(catalog)?;
408		let raw = unsafe { output.as_ref() }.ok_or(Error::InvalidPointer)?;
409
410		let mut config = moq_audio::decode::Config::default();
411		config.format = audio_format_from_u32(raw.format)?;
412		config.sample_rate = zeroable(raw.sample_rate);
413		config.channels = zeroable(raw.channels);
414		config.latency_max = (raw.latency_max_ms != 0).then(|| Duration::from_millis(raw.latency_max_ms));
415
416		let on_frame = unsafe { OnStatus::new(user_data, on_frame) };
417
418		let mut state = State::lock();
419		let (broadcast, audio_cfg, name) = state.consume.audio_rendition(catalog, index as usize)?;
420
421		let State { audio, .. } = &mut *state;
422		audio.consume(&broadcast, &audio_cfg, &name, config, on_frame)
423	})
424}
425
426/// Stop an audio (raw PCM) consumer's background task.
427///
428/// Returns immediately: zero on success, or a negative code if already closed.
429/// Does NOT free `user_data`; the on-frame callback still fires once more with a
430/// terminal `0` (or a negative error), which is where `user_data` should be
431/// released. Frame IDs already delivered to the callback are likewise not freed;
432/// release each with [`moq_consume_audio_raw_frame_free`].
433#[unsafe(no_mangle)]
434pub extern "C" fn moq_consume_audio_raw_close(consumer: u32) -> i32 {
435	ffi::enter(move || {
436		let consumer = ffi::parse_id(consumer)?;
437		State::lock().audio.consume_close(consumer)
438	})
439}
440
441/// Copy a delivered frame's metadata into `dst`.
442///
443/// The written `dst->data` pointer remains valid until the same `id`
444/// is released with [`moq_consume_audio_raw_frame_free`].
445///
446/// # Safety
447/// - `dst` must point to a writable [`moq_audio_frame`].
448#[unsafe(no_mangle)]
449pub unsafe extern "C" fn moq_consume_audio_raw_frame(id: u32, dst: *mut moq_audio_frame) -> i32 {
450	ffi::enter(move || {
451		let id = ffi::parse_id(id)?;
452		let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
453		State::lock().audio.frame_info(id, dst)
454	})
455}
456
457/// Free a frame previously delivered through the consume callback.
458/// Required for every delivered frame ID; closing the parent consumer
459/// is not enough.
460#[unsafe(no_mangle)]
461pub extern "C" fn moq_consume_audio_raw_frame_free(id: u32) -> i32 {
462	ffi::enter(move || {
463		let id = ffi::parse_id(id)?;
464		State::lock().audio.frame_free(id)
465	})
466}