Skip to main content

moq_audio/encode/
encoder.rs

1//! Audio encoder front end.
2//!
3//! [`Encoder`] dispatches over the closed [`Codec`] set. Opus wraps libopus
4//! 1.3.1 via [`unsafe_libopus`], while PCM serializes interleaved `f32` samples
5//! directly.
6
7use std::str::FromStr;
8use std::time::Duration;
9
10use bytes::Bytes;
11use unsafe_libopus::{
12	OPUS_APPLICATION_AUDIO, OPUS_GET_BITRATE_REQUEST, OPUS_GET_LOOKAHEAD_REQUEST, OPUS_OK, OPUS_RESET_STATE,
13	OPUS_SET_BITRATE_REQUEST, OPUS_SET_DTX_REQUEST, OPUS_SET_INBAND_FEC_REQUEST, OpusEncoder, opus_encode_float,
14	opus_encoder_create, opus_encoder_ctl_impl, opus_encoder_destroy, varargs,
15};
16
17use crate::opus;
18use crate::pcm;
19use crate::{Error, Format};
20
21/// libopus packet size ceiling per RFC 6716 ยง3.4.
22const MAX_PACKET_BYTES: usize = 4_000;
23
24/// Output audio codec. `#[non_exhaustive]` so new codecs can be added without
25/// breaking external `match`es.
26#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
27#[non_exhaustive]
28pub enum Codec {
29	/// Opus (RFC 6716), and the default.
30	#[default]
31	Opus,
32	/// Uncompressed interleaved little-endian IEEE-754 binary32 PCM.
33	Pcm,
34}
35
36impl Codec {
37	/// Canonical lowercase identifier, matching the WebCodecs / RFC catalog
38	/// string. Used as the wire/FFI codec name everywhere.
39	pub fn as_str(self) -> &'static str {
40		match self {
41			Self::Opus => "opus",
42			Self::Pcm => "pcm",
43		}
44	}
45}
46
47impl std::fmt::Display for Codec {
48	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49		f.write_str(self.as_str())
50	}
51}
52
53impl FromStr for Codec {
54	type Err = Error;
55
56	fn from_str(s: &str) -> Result<Self, Self::Err> {
57		match s {
58			"opus" => Ok(Self::Opus),
59			"pcm" => Ok(Self::Pcm),
60			other => Err(Error::Unsupported(format!("unknown codec: {other}"))),
61		}
62	}
63}
64
65/// The PCM layout of the buffers handed to [`Encoder::encode`] /
66/// [`Producer::write`](super::Producer::write).
67///
68/// The encoder's counterpart to a video encoder's width / height: it describes
69/// the input, not the output. `publish_capture` fills it in from the capture
70/// source, so only a bring-your-own-PCM caller builds one.
71#[derive(Clone, Debug)]
72pub struct Input {
73	/// How samples are packed in each buffer.
74	pub format: Format,
75	/// Samples per second per channel. Resampled to the codec rate if they differ.
76	pub sample_rate: u32,
77	/// Channels per frame.
78	pub channels: u32,
79}
80
81impl Default for Input {
82	fn default() -> Self {
83		Self {
84			format: Format::F32,
85			sample_rate: 48_000,
86			channels: 2,
87		}
88	}
89}
90
91/// Encoder configuration: the input PCM layout plus the codec knobs.
92///
93/// The bring-your-own-PCM counterpart to [`Options`](super::Options), which
94/// `publish_capture` uses when the layout comes from the capture source instead
95/// of the caller.
96///
97/// `#[non_exhaustive]`: build via [`Config::new`] and set the optional fields,
98/// so future knobs don't break callers.
99#[derive(Clone, Debug)]
100#[non_exhaustive]
101pub struct Config {
102	/// The PCM layout fed to the encoder.
103	pub input: Input,
104	/// Output codec. Defaults to [`Codec::Opus`].
105	pub codec: Codec,
106	/// Sample rate the codec runs at. `None` snaps [`Input::sample_rate`] up to
107	/// the nearest rate the codec supports, resampling if that moved it.
108	pub sample_rate: Option<u32>,
109	/// Channel count the codec runs at. `None` matches [`Input::channels`];
110	/// anything else is rejected, since remapping isn't implemented.
111	pub channels: Option<u32>,
112	/// Bitrate in bits per second. `None` lets Opus pick. PCM requires `None`
113	/// because its bitrate is fixed by the sample rate and channel count.
114	pub bitrate: Option<u32>,
115	/// Enable Opus in-band forward error correction.
116	pub fec: bool,
117	/// Enable Opus discontinuous transmission during silence.
118	pub dtx: bool,
119	/// Encoded frame duration. Opus accepts 2.5 / 5 / 10 / 20 / 40 / 60 ms.
120	/// PCM accepts any duration containing a whole number of samples.
121	pub frame_duration: Duration,
122}
123
124impl Config {
125	/// A config encoding `input` with the default codec settings.
126	pub fn new(input: Input) -> Self {
127		Self {
128			input,
129			codec: Codec::default(),
130			sample_rate: None,
131			channels: None,
132			bitrate: None,
133			fec: false,
134			dtx: false,
135			frame_duration: Duration::from_millis(20),
136		}
137	}
138}
139
140/// Audio encoder over the PCM layout declared in [`Config::input`].
141///
142/// Build one with [`Encoder::new`], feed full PCM frames via
143/// [`encode`](Self::encode), then pass the trailing partial frame to
144/// [`finish`](Self::finish). Publish every packet either call returns and apply
145/// the terminal [`Finish::discard_padding`] when the container supports it.
146pub struct Encoder {
147	backend: Backend,
148	config: Config,
149	/// Resolved codec sample rate (from `config.sample_rate`, else the input rate
150	/// snapped up to a supported one).
151	codec_rate: u32,
152	/// Resolved codec channel count (currently always the input's).
153	codec_channels: u32,
154	/// Current libopus target bitrate.
155	bitrate: u64,
156	/// Encoder lookahead expressed in the OpusHead 48 kHz timebase.
157	pre_skip: u16,
158	/// Encoder lookahead in codec-rate frames.
159	lookahead: usize,
160	frame_size: usize,
161	/// Whether input has reached the codec, since a fresh encoder owes no drain.
162	started: bool,
163}
164
165enum Backend {
166	Opus(Opus),
167	Pcm,
168}
169
170struct Opus {
171	inner: *mut OpusEncoder,
172	scratch: Vec<u8>,
173}
174
175// SAFETY: OpusEncoder is heap-allocated state owned exclusively by this
176// struct; libopus encoder methods take a single &mut, so a unique owner is
177// allowed to move it across threads.
178unsafe impl Send for Opus {}
179
180/// Packets emitted by [`Encoder::finish`] and the decoded padding at their end.
181pub struct Finish {
182	packets: Vec<Bytes>,
183	discard_padding: usize,
184}
185
186impl Finish {
187	/// Encoded packets in decode order.
188	pub fn packets(&self) -> &[Bytes] {
189		&self.packets
190	}
191
192	/// Decoded frames per channel to discard from the end of the final packet.
193	pub fn discard_padding(&self) -> usize {
194		self.discard_padding
195	}
196
197	/// Consume the result and return its encoded packets.
198	pub fn into_packets(self) -> Vec<Bytes> {
199		self.packets
200	}
201}
202
203impl Encoder {
204	/// Open an encoder for `config`.
205	pub fn new(config: &Config) -> Result<Self, Error> {
206		match config.codec {
207			Codec::Opus => Self::new_opus(config.clone()),
208			Codec::Pcm => Self::new_pcm(config.clone()),
209		}
210	}
211
212	fn new_opus(config: Config) -> Result<Self, Error> {
213		let codec_rate = config
214			.sample_rate
215			.unwrap_or_else(|| opus::pick_rate(config.input.sample_rate));
216		opus::validate_rate(codec_rate)?;
217
218		let codec_channels = config.channels.unwrap_or(config.input.channels);
219		if codec_channels != config.input.channels {
220			return Err(Error::Unsupported(format!(
221				"channel remapping not implemented (input {}ch, output {codec_channels}ch)",
222				config.input.channels
223			)));
224		}
225		let channels = opus::validate_channels(codec_channels)?;
226
227		let frame_size = opus::frame_size(codec_rate, config.frame_duration)?;
228
229		let mut err = 0i32;
230		// SAFETY: out-pointer `err` is valid; inner is checked for null below.
231		let inner = unsafe { opus_encoder_create(codec_rate as i32, channels, OPUS_APPLICATION_AUDIO, &mut err) };
232		if err != OPUS_OK || inner.is_null() {
233			return Err(opus::error(err, "opus_encoder_create"));
234		}
235
236		let configured = Self::configure_opus(inner, &config, codec_rate, codec_channels);
237		let (bitrate, lookahead, pre_skip) = match configured {
238			Ok(configured) => configured,
239			Err(err) => {
240				// SAFETY: `inner` was created above and not yet handed out.
241				unsafe { opus_encoder_destroy(inner) };
242				return Err(err);
243			}
244		};
245
246		Ok(Self {
247			backend: Backend::Opus(Opus {
248				inner,
249				scratch: vec![0u8; MAX_PACKET_BYTES],
250			}),
251			config,
252			codec_rate,
253			codec_channels,
254			bitrate,
255			pre_skip,
256			lookahead,
257			frame_size,
258			started: false,
259		})
260	}
261
262	fn new_pcm(config: Config) -> Result<Self, Error> {
263		if config.bitrate.is_some() {
264			return Err(Error::Unsupported(
265				"pcm bitrate is fixed; leave Config::bitrate unset".into(),
266			));
267		}
268
269		let codec_rate = config.sample_rate.unwrap_or(config.input.sample_rate);
270		if codec_rate == 0 {
271			return Err(Error::Unsupported("pcm sample rate must be greater than zero".into()));
272		}
273
274		let codec_channels = config.channels.unwrap_or(config.input.channels);
275		if codec_channels == 0 {
276			return Err(Error::Unsupported("pcm channel count must be greater than zero".into()));
277		}
278		if codec_channels != config.input.channels {
279			return Err(Error::Unsupported(format!(
280				"channel remapping not implemented (input {}ch, output {codec_channels}ch)",
281				config.input.channels
282			)));
283		}
284
285		let frame_size = pcm::frame_size(codec_rate, config.frame_duration)?;
286		pcm::frame_bytes(frame_size, codec_channels)?;
287		let bitrate = pcm::bitrate(codec_rate, codec_channels)?;
288		Ok(Self {
289			backend: Backend::Pcm,
290			config,
291			codec_rate,
292			codec_channels,
293			bitrate,
294			pre_skip: 0,
295			lookahead: 0,
296			frame_size,
297			started: false,
298		})
299	}
300
301	fn configure_opus(
302		inner: *mut OpusEncoder,
303		config: &Config,
304		codec_rate: u32,
305		codec_channels: u32,
306	) -> Result<(u64, usize, u16), Error> {
307		if let Some(bitrate) = config.bitrate {
308			Self::set_opus_bitrate(inner, codec_channels, bitrate as u64)?;
309		}
310		Self::set_opus_ctl(
311			inner,
312			OPUS_SET_INBAND_FEC_REQUEST,
313			i32::from(config.fec),
314			"OPUS_SET_INBAND_FEC",
315		)?;
316		Self::set_opus_ctl(inner, OPUS_SET_DTX_REQUEST, i32::from(config.dtx), "OPUS_SET_DTX")?;
317
318		let bitrate = Self::get_opus_ctl(inner, OPUS_GET_BITRATE_REQUEST, "OPUS_GET_BITRATE")?;
319		let bitrate = u64::try_from(bitrate)
320			.map_err(|_| Error::Unsupported(format!("Opus reported negative bitrate {bitrate}")))?;
321		let lookahead = Self::get_opus_ctl(inner, OPUS_GET_LOOKAHEAD_REQUEST, "OPUS_GET_LOOKAHEAD")?;
322		let lookahead = u64::try_from(lookahead)
323			.map_err(|_| Error::Unsupported(format!("Opus reported negative lookahead {lookahead}")))?;
324		let pre_skip = u16::try_from((lookahead * 48_000) / codec_rate as u64)
325			.map_err(|_| Error::Unsupported(format!("Opus lookahead {lookahead} does not fit in OpusHead")))?;
326		let lookahead = usize::try_from(lookahead)
327			.map_err(|_| Error::Unsupported(format!("Opus lookahead {lookahead} does not fit in memory")))?;
328
329		Ok((bitrate, lookahead, pre_skip))
330	}
331
332	fn set_opus_bitrate(inner: *mut OpusEncoder, channels: u32, bitrate: u64) -> Result<(), Error> {
333		let max = 300_000 * channels as u64;
334		if !(500..=max).contains(&bitrate) {
335			return Err(Error::Unsupported(format!(
336				"Opus bitrate must be between 500 and {max} bits per second for {channels} channel(s), got {bitrate}"
337			)));
338		}
339		Self::set_opus_ctl(inner, OPUS_SET_BITRATE_REQUEST, bitrate as i32, "OPUS_SET_BITRATE")
340	}
341
342	fn set_opus_ctl(inner: *mut OpusEncoder, request: i32, value: i32, name: &'static str) -> Result<(), Error> {
343		// SAFETY: `inner` owns a live encoder and each request here expects one i32.
344		let rc = unsafe { opus_encoder_ctl_impl(inner, request, varargs![value]) };
345		if rc != OPUS_OK {
346			return Err(opus::error(rc, name));
347		}
348		Ok(())
349	}
350
351	fn get_opus_ctl(inner: *mut OpusEncoder, request: i32, name: &'static str) -> Result<i32, Error> {
352		let mut value = 0;
353		// SAFETY: `inner` owns a live encoder and each request here expects one
354		// valid mutable i32 output.
355		let rc = unsafe { opus_encoder_ctl_impl(inner, request, varargs![&mut value]) };
356		if rc != OPUS_OK {
357			return Err(opus::error(rc, name));
358		}
359		Ok(value)
360	}
361
362	/// The encoder config, including the latest accepted runtime bitrate.
363	pub fn config(&self) -> &Config {
364		&self.config
365	}
366
367	/// The codec this encoder emits. A [`Producer`](super::Producer) must be
368	/// built for the same codec to publish its packets.
369	pub fn codec(&self) -> Codec {
370		self.config.codec
371	}
372
373	/// Sample rate the codec actually runs at, which is
374	/// [`Config::sample_rate`] resolved.
375	pub fn codec_rate(&self) -> u32 {
376		self.codec_rate
377	}
378
379	/// Channel count the codec actually runs at, which is
380	/// [`Config::channels`] resolved.
381	pub fn codec_channels(&self) -> u32 {
382		self.codec_channels
383	}
384
385	/// Number of samples per channel the codec consumes per call to
386	/// [`encode`](Self::encode).
387	pub fn frame_size(&self) -> usize {
388		self.frame_size
389	}
390
391	/// Current target bitrate in bits per second.
392	pub fn bitrate(&self) -> u64 {
393		self.bitrate
394	}
395
396	/// Retune the live Opus encoder to `bitrate` bits per second.
397	pub fn set_bitrate(&mut self, bitrate: u64) -> Result<(), Error> {
398		let Backend::Opus(opus) = &mut self.backend else {
399			return Err(Error::Unsupported("pcm bitrate is fixed".into()));
400		};
401		if bitrate != self.bitrate {
402			Self::set_opus_bitrate(opus.inner, self.codec_channels, bitrate)?;
403			self.bitrate = bitrate;
404			self.config.bitrate = Some(bitrate as u32);
405		}
406		Ok(())
407	}
408
409	/// Drop all codec history so a later epoch cannot emit audio from this one.
410	pub(super) fn reset(&mut self) {
411		if let Backend::Opus(opus) = &mut self.backend {
412			// SAFETY: `inner` owns a live encoder and OPUS_RESET_STATE takes no arguments.
413			let rc = unsafe { opus_encoder_ctl_impl(opus.inner, OPUS_RESET_STATE, varargs![]) };
414			debug_assert_eq!(rc, OPUS_OK, "OPUS_RESET_STATE failed with {rc}");
415		}
416		self.started = false;
417	}
418
419	/// Whether this epoch has submitted audio to the codec.
420	pub(super) fn started(&self) -> bool {
421		self.started
422	}
423
424	/// Encode one frame of interleaved `f32` PCM at [`codec_rate`](Self::codec_rate).
425	///
426	/// `pcm.len()` must equal `frame_size() * codec_channels()`. The
427	/// [`Producer`](super::Producer) handles format conversion and resampling
428	/// before calling this; for direct use, the caller does the same.
429	pub fn encode(&mut self, pcm: &[f32]) -> Result<Bytes, Error> {
430		let expected = self.frame_size * self.codec_channels as usize;
431		if pcm.len() != expected {
432			return Err(Error::Misaligned {
433				got: std::mem::size_of_val(pcm),
434				expected: expected * std::mem::size_of::<f32>(),
435			});
436		}
437		let packet = match &mut self.backend {
438			Backend::Opus(opus) => {
439				// SAFETY: `inner` owns a live OpusEncoder; pcm and scratch slices
440				// are bounded by the lengths we pass.
441				let n = unsafe {
442					opus_encode_float(
443						opus.inner,
444						pcm.as_ptr(),
445						self.frame_size as i32,
446						opus.scratch.as_mut_ptr(),
447						opus.scratch.len() as i32,
448					)
449				};
450				if n < 0 {
451					return Err(crate::opus::error(n, "opus_encode_float"));
452				}
453				Bytes::copy_from_slice(&opus.scratch[..n as usize])
454			}
455			Backend::Pcm => {
456				let mut payload = Vec::with_capacity(std::mem::size_of_val(pcm));
457				for sample in pcm {
458					payload.extend_from_slice(&sample.to_le_bytes());
459				}
460				payload.into()
461			}
462		};
463		self.started = true;
464		Ok(packet)
465	}
466
467	/// Finish encoding, zero-padding `pcm` as the final partial frame and
468	/// returning every packet needed to drain codec lookahead.
469	///
470	/// `pcm` is interleaved at [`codec_rate`](Self::codec_rate), may be empty,
471	/// and must contain at most one frame. Silence added here only drains
472	/// audio already supplied; [`Finish::discard_padding`] reports how much of
473	/// the decoded tail is artificial and must not count as source duration.
474	/// Consuming the encoder prevents encoding across the artificial terminal
475	/// padding.
476	pub fn finish(mut self, pcm: &[f32]) -> Result<Finish, Error> {
477		let channels = self.codec_channels as usize;
478		let frame_samples = self.frame_size * channels;
479		if pcm.len() > frame_samples || !pcm.len().is_multiple_of(channels) {
480			return Err(Error::Misaligned {
481				got: std::mem::size_of_val(pcm),
482				expected: if pcm.len() > frame_samples {
483					frame_samples * std::mem::size_of::<f32>()
484				} else {
485					pcm.len().next_multiple_of(channels) * std::mem::size_of::<f32>()
486				},
487			});
488		}
489
490		let source_frames = pcm.len() / channels;
491		let mut packets = Vec::new();
492		let padding = if pcm.is_empty() {
493			0
494		} else {
495			let mut frame = Vec::with_capacity(frame_samples);
496			frame.extend_from_slice(pcm);
497			frame.resize(frame_samples, 0.0);
498			let padding = (frame_samples - pcm.len()) / channels;
499			packets.push(self.encode(&frame)?);
500			padding
501		};
502
503		if !self.started {
504			return Ok(Finish {
505				packets,
506				discard_padding: 0,
507			});
508		}
509
510		let drain = self.lookahead.saturating_sub(padding);
511		let silence = vec![0.0; frame_samples];
512		for _ in 0..drain.div_ceil(self.frame_size) {
513			packets.push(self.encode(&silence)?);
514		}
515
516		let discard_padding = packets
517			.len()
518			.saturating_mul(self.frame_size)
519			.saturating_sub(self.lookahead)
520			.saturating_sub(source_frames);
521
522		Ok(Finish {
523			packets,
524			discard_padding,
525		})
526	}
527
528	/// hang catalog entry describing this encoder's output stream.
529	pub fn catalog(&self) -> hang::catalog::AudioConfig {
530		match self.config.codec {
531			Codec::Opus => {
532				// `codec_channels` is validated to mono/stereo at encoder construction,
533				// so the OpusHead (channel mapping family 0) always encodes.
534				let head = moq_mux::codec::opus::Config::new(self.codec_rate, self.codec_channels)
535					.with_pre_skip(self.pre_skip)
536					.encode()
537					.expect("opus encoder channels validated to mono/stereo");
538
539				let mut config = hang::catalog::AudioConfig::new(
540					hang::catalog::AudioCodec::Opus,
541					self.codec_rate,
542					self.codec_channels,
543				);
544				config.bitrate = self.config.bitrate.map(u64::from);
545				config.description = Some(head);
546				config.container = hang::catalog::Container::Legacy;
547				config
548			}
549			Codec::Pcm => {
550				let mut config = hang::catalog::AudioConfig::new(
551					hang::catalog::AudioCodec::Pcm,
552					self.codec_rate,
553					self.codec_channels,
554				);
555				config.bitrate = Some(
556					pcm::bitrate(self.codec_rate, self.codec_channels)
557						.expect("pcm encoder bitrate validated at construction"),
558				);
559				config.container = hang::catalog::Container::Legacy;
560				config
561			}
562		}
563	}
564}
565
566impl Drop for Opus {
567	fn drop(&mut self) {
568		// SAFETY: `inner` is a live OpusEncoder that nothing else aliases.
569		unsafe { opus_encoder_destroy(self.inner) };
570	}
571}
572
573#[cfg(test)]
574mod tests {
575	use super::*;
576	use crate::decode::Decoder;
577
578	fn sine(freq: f32, sample_rate: u32, channels: u32, frames: usize) -> Vec<f32> {
579		let mut out = Vec::with_capacity(frames * channels as usize);
580		for i in 0..frames {
581			let t = i as f32 / sample_rate as f32;
582			let v = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.5;
583			for _ in 0..channels {
584				out.push(v);
585			}
586		}
587		out
588	}
589
590	fn stereo_48k() -> Input {
591		Input {
592			format: Format::F32,
593			sample_rate: 48_000,
594			channels: 2,
595		}
596	}
597
598	fn opus_inner(encoder: &Encoder) -> *mut OpusEncoder {
599		let Backend::Opus(opus) = &encoder.backend else {
600			panic!("expected Opus encoder");
601		};
602		opus.inner
603	}
604
605	#[test]
606	fn opus_encode_then_decode_keeps_signal_close() {
607		let mut enc = Encoder::new(&Config {
608			bitrate: Some(96_000),
609			..Config::new(stereo_48k())
610		})
611		.unwrap();
612
613		let cfg = enc.catalog();
614		let mut dec = Decoder::new(&cfg).unwrap();
615
616		let frame = sine(440.0, 48_000, 2, enc.frame_size());
617		for _ in 0..5 {
618			let pkt = enc.encode(&frame).unwrap();
619			let _ = dec.decode(&pkt).unwrap();
620		}
621
622		let pkt = enc.encode(&frame).unwrap();
623		let decoded = dec.decode(&pkt).unwrap();
624		assert_eq!(decoded.len(), frame.len());
625
626		let energy_in: f32 = frame.iter().map(|s| s * s).sum();
627		let energy_out: f32 = decoded.iter().map(|s| s * s).sum();
628		let ratio = energy_out / energy_in;
629		assert!(
630			(0.5..2.0).contains(&ratio),
631			"output energy ratio {ratio:.3} should be close to 1"
632		);
633	}
634
635	#[test]
636	fn opus_rejects_unsupported_frame_duration() {
637		let err = Encoder::new(&Config {
638			frame_duration: Duration::from_millis(15),
639			..Config::new(Input::default())
640		});
641		assert!(matches!(err, Err(Error::Unsupported(_))));
642	}
643
644	#[test]
645	fn opus_rejects_misaligned_input() {
646		let mut enc = Encoder::new(&Config::new(Input::default())).unwrap();
647		assert!(matches!(enc.encode(&[0.0f32; 100]), Err(Error::Misaligned { .. })));
648	}
649
650	#[test]
651	fn opus_catalog_includes_opushead() {
652		let enc = Encoder::new(&Config {
653			bitrate: Some(64_000),
654			..Config::new(stereo_48k())
655		})
656		.unwrap();
657		let cfg = enc.catalog();
658		assert_eq!(cfg.sample_rate, 48_000);
659		assert_eq!(cfg.channel_count, 2);
660		assert_eq!(cfg.bitrate, Some(64_000));
661		let desc = cfg.description.expect("OpusHead should be present");
662		assert_eq!(desc.len(), 19);
663		let head = moq_mux::codec::opus::Config::parse(&mut desc.as_ref()).unwrap();
664		assert_eq!(head.pre_skip, enc.pre_skip);
665		assert_eq!(head.pre_skip, 312);
666	}
667
668	#[test]
669	fn opus_decoder_trims_encoder_lookahead_once() {
670		let mut enc = Encoder::new(&Config::new(stereo_48k())).unwrap();
671		let mut dec = Decoder::new(&enc.catalog()).unwrap();
672		let frame = vec![0.0; enc.frame_size() * enc.codec_channels() as usize];
673
674		let first = dec.decode(&enc.encode(&frame).unwrap()).unwrap();
675		assert_eq!(
676			first.len(),
677			(enc.frame_size() - enc.pre_skip as usize) * enc.codec_channels() as usize
678		);
679
680		let second = dec.decode(&enc.encode(&frame).unwrap()).unwrap();
681		assert_eq!(second.len(), frame.len());
682	}
683
684	#[test]
685	fn opus_finish_accounts_for_partial_frame_padding() {
686		let enc = Encoder::new(&Config::new(Input {
687			channels: 1,
688			..Input::default()
689		}))
690		.unwrap();
691
692		// The 360 frames of terminal padding exceed the 312-frame lookahead,
693		// so the partial packet itself completes the drain.
694		let packets = enc.finish(&vec![0.0; 600]).unwrap();
695		assert_eq!(packets.packets().len(), 1);
696		assert_eq!(packets.discard_padding(), 48);
697	}
698
699	#[test]
700	fn opus_finish_drains_lookahead_across_multiple_short_packets() {
701		let mut enc = Encoder::new(&Config {
702			frame_duration: Duration::from_micros(2_500),
703			..Config::new(Input {
704				channels: 1,
705				..Input::default()
706			})
707		})
708		.unwrap();
709		let frame = vec![0.0; enc.frame_size()];
710		enc.encode(&frame).unwrap();
711
712		// Three 120-frame packets are required to push out 312 frames.
713		let packets = enc.finish(&[]).unwrap();
714		assert_eq!(packets.packets().len(), 3);
715		assert_eq!(packets.discard_padding(), 48);
716	}
717
718	#[test]
719	fn reset_drops_pending_opus_lookahead() {
720		let mut enc = Encoder::new(&Config::new(Input {
721			channels: 1,
722			..Input::default()
723		}))
724		.unwrap();
725		let mut old = vec![0.0; enc.frame_size()];
726		old[enc.frame_size() - 1] = 1.0;
727		enc.encode(&old).unwrap();
728
729		enc.reset();
730		let next = vec![0.0; enc.frame_size()];
731		let actual = enc.encode(&next).unwrap();
732		let mut decoder = Decoder::new(&enc.catalog()).unwrap();
733		let decoded = decoder.decode(&actual).unwrap();
734		let peak = decoded.iter().fold(0.0f32, |peak, sample| peak.max(sample.abs()));
735		assert!(peak < 0.001, "pre-reset impulse leaked into the next epoch: {peak}");
736
737		enc.reset();
738		let finish = enc.finish(&[]).unwrap();
739		assert!(finish.packets().is_empty());
740		assert_eq!(finish.discard_padding(), 0);
741	}
742
743	#[test]
744	fn opus_runtime_bitrate_updates_encoder_state() {
745		let mut enc = Encoder::new(&Config {
746			bitrate: Some(64_000),
747			..Config::new(stereo_48k())
748		})
749		.unwrap();
750
751		enc.set_bitrate(32_000).unwrap();
752		assert_eq!(enc.bitrate(), 32_000);
753		assert_eq!(enc.config().bitrate, Some(32_000));
754		assert_eq!(
755			Encoder::get_opus_ctl(
756				opus_inner(&enc),
757				unsafe_libopus::OPUS_GET_BITRATE_REQUEST,
758				"OPUS_GET_BITRATE"
759			)
760			.unwrap(),
761			32_000
762		);
763	}
764
765	#[test]
766	fn opus_runtime_bitrate_rejects_values_libopus_would_clamp() {
767		let mut enc = Encoder::new(&Config::new(stereo_48k())).unwrap();
768		let original = enc.bitrate();
769		assert!(enc.set_bitrate(1).is_err());
770		assert!(enc.set_bitrate(600_001).is_err());
771		assert_eq!(enc.bitrate(), original);
772	}
773
774	#[test]
775	fn opus_applies_fec_and_dtx_controls() {
776		let enc = Encoder::new(&Config {
777			fec: true,
778			dtx: true,
779			..Config::new(stereo_48k())
780		})
781		.unwrap();
782
783		assert_eq!(
784			Encoder::get_opus_ctl(
785				opus_inner(&enc),
786				unsafe_libopus::OPUS_GET_INBAND_FEC_REQUEST,
787				"OPUS_GET_INBAND_FEC"
788			)
789			.unwrap(),
790			1
791		);
792		assert_eq!(
793			Encoder::get_opus_ctl(opus_inner(&enc), unsafe_libopus::OPUS_GET_DTX_REQUEST, "OPUS_GET_DTX").unwrap(),
794			1
795		);
796	}
797
798	#[test]
799	fn codec_roundtrips_as_str() {
800		assert_eq!(Codec::Opus.as_str(), "opus");
801		assert_eq!(Codec::Opus.to_string(), "opus");
802		assert_eq!("opus".parse::<Codec>().unwrap(), Codec::Opus);
803		assert_eq!(Codec::Pcm.as_str(), "pcm");
804		assert_eq!(Codec::Pcm.to_string(), "pcm");
805		assert_eq!("pcm".parse::<Codec>().unwrap(), Codec::Pcm);
806		assert!("aac".parse::<Codec>().is_err());
807	}
808
809	#[test]
810	fn config_sample_rate_overrides_the_codec_rate() {
811		let enc = Encoder::new(&Config {
812			sample_rate: Some(24_000),
813			..Config::new(Input {
814				sample_rate: 48_000,
815				channels: 1,
816				..Input::default()
817			})
818		})
819		.unwrap();
820		assert_eq!(enc.codec_rate(), 24_000);
821		assert_eq!(enc.catalog().sample_rate, 24_000);
822		assert_eq!(enc.pre_skip, 312);
823	}
824
825	#[test]
826	fn pcm_roundtrip_is_lossless() {
827		let mut enc = Encoder::new(&Config {
828			codec: Codec::Pcm,
829			..Config::new(stereo_48k())
830		})
831		.unwrap();
832		let mut dec = Decoder::new(&enc.catalog()).unwrap();
833		let input = sine(440.0, enc.codec_rate(), enc.codec_channels(), enc.frame_size());
834
835		let packet = enc.encode(&input).unwrap();
836		let output = dec.decode(&packet).unwrap();
837
838		assert_eq!(output, input);
839	}
840
841	#[test]
842	fn pcm_catalog_declares_fixed_bitrate() {
843		let enc = Encoder::new(&Config {
844			codec: Codec::Pcm,
845			..Config::new(stereo_48k())
846		})
847		.unwrap();
848		let catalog = enc.catalog();
849
850		assert_eq!(catalog.codec, hang::catalog::AudioCodec::Pcm);
851		assert_eq!(catalog.bitrate, Some(48_000 * 2 * 32));
852		assert_eq!(catalog.description, None);
853	}
854
855	#[test]
856	fn pcm_rejects_runtime_bitrate_change() {
857		let mut enc = Encoder::new(&Config {
858			codec: Codec::Pcm,
859			..Config::new(stereo_48k())
860		})
861		.unwrap();
862		let bitrate = enc.bitrate();
863
864		assert!(matches!(enc.set_bitrate(bitrate), Err(Error::Unsupported(_))));
865		assert_eq!(enc.bitrate(), bitrate);
866	}
867
868	#[test]
869	fn pcm_rejects_fractional_sample_frame_duration() {
870		let err = Encoder::new(&Config {
871			codec: Codec::Pcm,
872			frame_duration: Duration::from_micros(2_500),
873			..Config::new(Input {
874				sample_rate: 44_100,
875				..Input::default()
876			})
877		});
878		assert!(matches!(err, Err(Error::Unsupported(_))));
879	}
880
881	#[test]
882	fn pcm_rejects_bitrate_overflow() {
883		let err = Encoder::new(&Config {
884			codec: Codec::Pcm,
885			frame_duration: Duration::from_secs(1),
886			..Config::new(Input {
887				sample_rate: u32::MAX,
888				channels: u32::MAX,
889				..Input::default()
890			})
891		});
892		assert!(matches!(err, Err(Error::Unsupported(_))));
893	}
894}