Skip to main content

moq_audio/
codec.rs

1//! Opus codec wrapper.
2//!
3//! Single-codec implementation today: [`Encoder`] / [`Decoder`] wrap
4//! libopus 1.3.1 via [`unsafe_libopus`], a pure-Rust c2rust
5//! transpilation. No CMake toolchain, no sys crate, no linker
6//! gymnastics. When AAC or other codecs land we'll factor out a
7//! `Codec` enum dispatch; introducing a trait now would be
8//! premature.
9
10use std::str::FromStr;
11use std::time::Duration;
12
13use bytes::Bytes;
14use unsafe_libopus::{
15	OPUS_APPLICATION_AUDIO, OPUS_OK, OPUS_SET_BITRATE_REQUEST, OpusDecoder, OpusEncoder, opus_decode_float,
16	opus_decoder_create, opus_decoder_destroy, opus_encode_float, opus_encoder_create, opus_encoder_ctl_impl,
17	opus_encoder_destroy, varargs,
18};
19
20use crate::{AudioError, AudioFormat};
21
22/// libopus packet size ceiling per RFC 6716 ยง3.4.
23const MAX_PACKET_BYTES: usize = 4_000;
24
25/// Codec identifier. Opus is the only variant today; AAC may follow.
26#[derive(Copy, Clone, Debug, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Codec {
29	Opus,
30}
31
32impl Codec {
33	/// Canonical lowercase identifier, matching the WebCodecs / RFC
34	/// catalog string. Used as the wire/FFI codec name everywhere.
35	pub fn as_str(self) -> &'static str {
36		match self {
37			Self::Opus => "opus",
38		}
39	}
40}
41
42impl std::fmt::Display for Codec {
43	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44		f.write_str(self.as_str())
45	}
46}
47
48impl FromStr for Codec {
49	type Err = AudioError;
50
51	fn from_str(s: &str) -> Result<Self, Self::Err> {
52		match s {
53			"opus" => Ok(Self::Opus),
54			other => Err(AudioError::Unsupported(format!("unknown codec: {other}"))),
55		}
56	}
57}
58
59/// PCM layout the caller hands to [`Encoder::encode_f32`] /
60/// `AudioProducer::write`.
61#[derive(Clone, Debug)]
62pub struct EncoderInput {
63	pub format: AudioFormat,
64	pub sample_rate: u32,
65	pub channels: u32,
66}
67
68impl Default for EncoderInput {
69	fn default() -> Self {
70		Self {
71			format: AudioFormat::F32,
72			sample_rate: 48_000,
73			channels: 2,
74		}
75	}
76}
77
78/// Codec-side configuration. `sample_rate` and `channels` are
79/// optional overrides; `None` means "match input (snapping the rate
80/// up to a libopus-supported value if necessary)".
81#[derive(Clone, Debug)]
82pub struct EncoderOutput {
83	pub codec: Codec,
84	pub sample_rate: Option<u32>,
85	pub channels: Option<u32>,
86	/// libopus bitrate in bits per second. `None` lets libopus pick.
87	pub bitrate: Option<u32>,
88	/// Encoded frame duration. Opus accepts 2.5 / 5 / 10 / 20 / 40 / 60 ms.
89	pub frame_duration: Duration,
90}
91
92impl Default for EncoderOutput {
93	fn default() -> Self {
94		Self {
95			codec: Codec::Opus,
96			sample_rate: None,
97			channels: None,
98			bitrate: None,
99			frame_duration: Duration::from_millis(20),
100		}
101	}
102}
103
104/// PCM layout the caller wants out of [`Decoder::decode_f32`] /
105/// `AudioConsumer::read`. `sample_rate` and `channels` `None`
106/// means "match the codec's native shape from the catalog".
107#[derive(Clone, Debug, Default)]
108pub struct DecoderOutput {
109	pub format: AudioFormat,
110	pub sample_rate: Option<u32>,
111	pub channels: Option<u32>,
112	/// Upper bound on buffering before skipping a stalled group.
113	///
114	/// Forwarded to [`moq_mux::container::Consumer::with_latency`]: if
115	/// a group is stuck and a newer group is more than this far ahead,
116	/// the consumer skips. `None` keeps the moq-mux default of zero,
117	/// which skips aggressively. Set to the playout buffer you can
118	/// tolerate (typically tens to a few hundred ms) for the best
119	/// congestion-vs-quality trade-off. The `_max` suffix is a
120	/// reminder that we never *add* latency here: the consumer skips
121	/// only when newer data is already this far ahead. A companion
122	/// `latency_min` for jitter-buffer padding will land in a follow-up.
123	pub latency_max: Option<Duration>,
124}
125
126fn validate_opus_channels(count: u32) -> Result<i32, AudioError> {
127	match count {
128		1 | 2 => Ok(count as i32),
129		other => Err(AudioError::Unsupported(format!(
130			"opus only supports 1 or 2 channels (got {other})"
131		))),
132	}
133}
134
135fn opus_error(code: i32, context: &str) -> AudioError {
136	AudioError::Unsupported(format!("libopus {context} failed (code {code})"))
137}
138
139/// Snap an arbitrary sample rate up to the nearest libopus-supported
140/// rate (8/12/16/24/48 kHz); falls back to 48 kHz for anything above.
141pub fn pick_opus_rate(input_rate: u32) -> u32 {
142	const SUPPORTED: [u32; 5] = [8_000, 12_000, 16_000, 24_000, 48_000];
143	SUPPORTED.iter().copied().find(|&r| r >= input_rate).unwrap_or(48_000)
144}
145
146fn validate_opus_rate(rate: u32) -> Result<(), AudioError> {
147	match rate {
148		8_000 | 12_000 | 16_000 | 24_000 | 48_000 => Ok(()),
149		other => Err(AudioError::Unsupported(format!(
150			"opus only supports 8/12/16/24/48 kHz (got {other})"
151		))),
152	}
153}
154
155fn frame_size_for(sample_rate: u32, duration: Duration) -> Result<usize, AudioError> {
156	// Opus only accepts these exact durations.
157	let micros = duration.as_micros();
158	let allowed = [2_500u128, 5_000, 10_000, 20_000, 40_000, 60_000];
159	if !allowed.contains(&micros) {
160		return Err(AudioError::Unsupported(format!(
161			"opus frame duration must be 2.5/5/10/20/40/60 ms (got {} us)",
162			micros
163		)));
164	}
165	Ok((sample_rate as u128 * micros / 1_000_000) as usize)
166}
167
168/// Opus encoder over the PCM layout declared in [`EncoderInput`].
169pub struct Encoder {
170	inner: *mut OpusEncoder,
171	input: EncoderInput,
172	output: EncoderOutput,
173	/// Resolved codec sample rate (from `output.sample_rate` or
174	/// `pick_opus_rate(input.sample_rate)`).
175	codec_rate: u32,
176	/// Resolved codec channel count (currently same as `input.channels`).
177	codec_channels: u32,
178	frame_size: usize,
179	scratch: Vec<u8>,
180}
181
182// SAFETY: OpusEncoder is heap-allocated state owned exclusively by this
183// struct; libopus encoder methods take a single &mut, so a unique
184// owner is allowed to move it across threads.
185unsafe impl Send for Encoder {}
186
187impl Encoder {
188	pub fn new(input: EncoderInput, output: EncoderOutput) -> Result<Self, AudioError> {
189		match output.codec {
190			Codec::Opus => Self::new_opus(input, output),
191		}
192	}
193
194	fn new_opus(input: EncoderInput, output: EncoderOutput) -> Result<Self, AudioError> {
195		let codec_rate = output.sample_rate.unwrap_or_else(|| pick_opus_rate(input.sample_rate));
196		validate_opus_rate(codec_rate)?;
197
198		let codec_channels = output.channels.unwrap_or(input.channels);
199		if codec_channels != input.channels {
200			return Err(AudioError::Unsupported(format!(
201				"channel remapping not implemented (input {}ch, output {codec_channels}ch)",
202				input.channels
203			)));
204		}
205		let channels = validate_opus_channels(codec_channels)?;
206
207		let frame_size = frame_size_for(codec_rate, output.frame_duration)?;
208
209		let mut err = 0i32;
210		// SAFETY: out-pointer `err` is valid; inner is checked for null below.
211		let inner = unsafe { opus_encoder_create(codec_rate as i32, channels, OPUS_APPLICATION_AUDIO, &mut err) };
212		if err != OPUS_OK || inner.is_null() {
213			return Err(opus_error(err, "opus_encoder_create"));
214		}
215
216		if let Some(b) = output.bitrate {
217			// SAFETY: `inner` is a freshly-created encoder; varargs! produces
218			// the single i32 the SET_BITRATE request expects.
219			let rc = unsafe { opus_encoder_ctl_impl(inner, OPUS_SET_BITRATE_REQUEST, varargs![b as i32]) };
220			if rc != OPUS_OK {
221				// SAFETY: `inner` was created above and not yet handed out.
222				unsafe { opus_encoder_destroy(inner) };
223				return Err(opus_error(rc, "OPUS_SET_BITRATE"));
224			}
225		}
226
227		Ok(Self {
228			inner,
229			input,
230			output,
231			codec_rate,
232			codec_channels,
233			frame_size,
234			scratch: vec![0u8; MAX_PACKET_BYTES],
235		})
236	}
237
238	pub fn input(&self) -> &EncoderInput {
239		&self.input
240	}
241
242	pub fn output(&self) -> &EncoderOutput {
243		&self.output
244	}
245
246	/// Sample rate libopus actually runs at.
247	pub fn codec_rate(&self) -> u32 {
248		self.codec_rate
249	}
250
251	/// Channel count libopus actually runs at.
252	pub fn codec_channels(&self) -> u32 {
253		self.codec_channels
254	}
255
256	/// Number of input frames libopus consumes per call.
257	pub fn frame_size(&self) -> usize {
258		self.frame_size
259	}
260
261	/// Encode one frame of interleaved `f32` PCM at `codec_rate`.
262	///
263	/// `pcm.len()` must equal `frame_size() * codec_channels()`. The
264	/// producer typically handles format conversion and resampling
265	/// before calling this; for direct use, the caller does the same.
266	pub fn encode_f32(&mut self, pcm: &[f32]) -> Result<Bytes, AudioError> {
267		let expected = self.frame_size * self.codec_channels as usize;
268		if pcm.len() != expected {
269			return Err(AudioError::Misaligned {
270				got: std::mem::size_of_val(pcm),
271				expected: expected * std::mem::size_of::<f32>(),
272			});
273		}
274		// SAFETY: `inner` owns a live OpusEncoder; pcm and scratch slices
275		// are bounded by the lengths we pass.
276		let n = unsafe {
277			opus_encode_float(
278				self.inner,
279				pcm.as_ptr(),
280				self.frame_size as i32,
281				self.scratch.as_mut_ptr(),
282				self.scratch.len() as i32,
283			)
284		};
285		if n < 0 {
286			return Err(opus_error(n, "opus_encode_float"));
287		}
288		Ok(Bytes::copy_from_slice(&self.scratch[..n as usize]))
289	}
290
291	/// hang catalog entry describing this encoder's output stream.
292	pub fn catalog(&self) -> hang::catalog::AudioConfig {
293		let head = moq_mux::codec::opus::Config {
294			sample_rate: self.codec_rate,
295			channel_count: self.codec_channels,
296		}
297		.encode();
298
299		let mut config =
300			hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, self.codec_rate, self.codec_channels);
301		config.bitrate = self.output.bitrate.map(|b| b as u64);
302		config.description = Some(head);
303		config.container = hang::catalog::Container::Legacy;
304		config
305	}
306}
307
308/// Opus decoder producing interleaved `f32` PCM.
309pub struct Decoder {
310	inner: *mut OpusDecoder,
311	sample_rate: u32,
312	channel_count: u32,
313	max_frame_size: usize,
314}
315
316// SAFETY: see Encoder above.
317unsafe impl Send for Decoder {}
318
319impl Decoder {
320	/// Build a decoder from a catalog [`AudioConfig`](hang::catalog::AudioConfig).
321	///
322	/// Parses the OpusHead `description` if present; falls back to the
323	/// catalog's declared sample rate / channel count.
324	pub fn new(catalog: &hang::catalog::AudioConfig) -> Result<Self, AudioError> {
325		let (sample_rate, channel_count) = if let Some(desc) = &catalog.description {
326			let mut buf = desc.as_ref();
327			match moq_mux::codec::opus::Config::parse(&mut buf) {
328				Ok(head) => (head.sample_rate, head.channel_count),
329				Err(_) => (catalog.sample_rate, catalog.channel_count),
330			}
331		} else {
332			(catalog.sample_rate, catalog.channel_count)
333		};
334
335		validate_opus_rate(sample_rate)?;
336		let channels = validate_opus_channels(channel_count)?;
337
338		let mut err = 0i32;
339		// SAFETY: out-pointer is valid; inner is checked for null below.
340		let inner = unsafe { opus_decoder_create(sample_rate as i32, channels, &mut err) };
341		if err != OPUS_OK || inner.is_null() {
342			return Err(opus_error(err, "opus_decoder_create"));
343		}
344
345		// Opus packets cap at 120 ms.
346		let max_frame_size = (sample_rate as usize * 120) / 1000;
347
348		Ok(Self {
349			inner,
350			sample_rate,
351			channel_count,
352			max_frame_size,
353		})
354	}
355
356	pub fn sample_rate(&self) -> u32 {
357		self.sample_rate
358	}
359
360	pub fn channel_count(&self) -> u32 {
361		self.channel_count
362	}
363
364	/// Decode one packet into interleaved `f32` PCM.
365	pub fn decode_f32(&mut self, packet: &[u8]) -> Result<Vec<f32>, AudioError> {
366		let mut out = vec![0.0f32; self.max_frame_size * self.channel_count as usize];
367		// SAFETY: `inner` owns a live OpusDecoder; packet/out slices bound
368		// by the lengths we pass.
369		let samples = unsafe {
370			opus_decode_float(
371				&mut *self.inner,
372				packet.as_ptr(),
373				packet.len() as i32,
374				out.as_mut_ptr(),
375				self.max_frame_size as i32,
376				0,
377			)
378		};
379		if samples < 0 {
380			return Err(opus_error(samples, "opus_decode_float"));
381		}
382		out.truncate(samples as usize * self.channel_count as usize);
383		Ok(out)
384	}
385}
386
387impl Drop for Encoder {
388	fn drop(&mut self) {
389		// SAFETY: `inner` is a live OpusEncoder that nothing else aliases.
390		unsafe { opus_encoder_destroy(self.inner) };
391	}
392}
393
394impl Drop for Decoder {
395	fn drop(&mut self) {
396		// SAFETY: same as Encoder.
397		unsafe { opus_decoder_destroy(self.inner) };
398	}
399}
400
401#[cfg(test)]
402mod tests {
403	use super::*;
404
405	fn sine(freq: f32, sample_rate: u32, channels: u32, frames: usize) -> Vec<f32> {
406		let mut out = Vec::with_capacity(frames * channels as usize);
407		for i in 0..frames {
408			let t = i as f32 / sample_rate as f32;
409			let v = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.5;
410			for _ in 0..channels {
411				out.push(v);
412			}
413		}
414		out
415	}
416
417	#[test]
418	fn opus_encode_then_decode_keeps_signal_close() {
419		let mut enc = Encoder::new(
420			EncoderInput {
421				format: AudioFormat::F32,
422				sample_rate: 48_000,
423				channels: 2,
424			},
425			EncoderOutput {
426				bitrate: Some(96_000),
427				..EncoderOutput::default()
428			},
429		)
430		.unwrap();
431
432		let cfg = enc.catalog();
433		let mut dec = Decoder::new(&cfg).unwrap();
434
435		let frame = sine(440.0, 48_000, 2, enc.frame_size());
436		for _ in 0..5 {
437			let pkt = enc.encode_f32(&frame).unwrap();
438			let _ = dec.decode_f32(&pkt).unwrap();
439		}
440
441		let pkt = enc.encode_f32(&frame).unwrap();
442		let decoded = dec.decode_f32(&pkt).unwrap();
443		assert_eq!(decoded.len(), frame.len());
444
445		let energy_in: f32 = frame.iter().map(|s| s * s).sum();
446		let energy_out: f32 = decoded.iter().map(|s| s * s).sum();
447		let ratio = energy_out / energy_in;
448		assert!(
449			(0.5..2.0).contains(&ratio),
450			"output energy ratio {ratio:.3} should be close to 1"
451		);
452	}
453
454	#[test]
455	fn opus_rejects_unsupported_frame_duration() {
456		let err = Encoder::new(
457			EncoderInput::default(),
458			EncoderOutput {
459				frame_duration: Duration::from_millis(15),
460				..EncoderOutput::default()
461			},
462		);
463		assert!(matches!(err, Err(AudioError::Unsupported(_))));
464	}
465
466	#[test]
467	fn opus_rejects_misaligned_input() {
468		let mut enc = Encoder::new(EncoderInput::default(), EncoderOutput::default()).unwrap();
469		assert!(matches!(
470			enc.encode_f32(&[0.0f32; 100]),
471			Err(AudioError::Misaligned { .. })
472		));
473	}
474
475	#[test]
476	fn opus_catalog_includes_opushead() {
477		let enc = Encoder::new(
478			EncoderInput {
479				sample_rate: 48_000,
480				channels: 2,
481				..EncoderInput::default()
482			},
483			EncoderOutput {
484				bitrate: Some(64_000),
485				..EncoderOutput::default()
486			},
487		)
488		.unwrap();
489		let cfg = enc.catalog();
490		assert_eq!(cfg.sample_rate, 48_000);
491		assert_eq!(cfg.channel_count, 2);
492		assert_eq!(cfg.bitrate, Some(64_000));
493		let desc = cfg.description.expect("OpusHead should be present");
494		assert_eq!(desc.len(), 19);
495	}
496
497	#[test]
498	fn rate_picker_snaps_up() {
499		assert_eq!(pick_opus_rate(44_100), 48_000);
500		assert_eq!(pick_opus_rate(22_050), 24_000);
501		for &r in &[8_000, 12_000, 16_000, 24_000, 48_000] {
502			assert_eq!(pick_opus_rate(r), r);
503		}
504	}
505
506	#[test]
507	fn codec_roundtrips_as_str() {
508		assert_eq!(Codec::Opus.as_str(), "opus");
509		assert_eq!(Codec::Opus.to_string(), "opus");
510		assert_eq!("opus".parse::<Codec>().unwrap(), Codec::Opus);
511		assert!("aac".parse::<Codec>().is_err());
512	}
513
514	#[test]
515	fn encoder_output_overrides_codec_rate() {
516		let enc = Encoder::new(
517			EncoderInput {
518				sample_rate: 48_000,
519				channels: 1,
520				..EncoderInput::default()
521			},
522			EncoderOutput {
523				sample_rate: Some(24_000),
524				..EncoderOutput::default()
525			},
526		)
527		.unwrap();
528		assert_eq!(enc.codec_rate(), 24_000);
529		assert_eq!(enc.catalog().sample_rate, 24_000);
530	}
531}