wavekat_core/codec/opus.rs
1//! Opus codec (RFC 6716) — the wideband voice profile for telephony.
2//!
3//! Wraps the reference C `libopus` via the [`opus`](https://docs.rs/opus)
4//! crate. The profile is fixed for the WaveKat call path: 16 kHz mono
5//! ("wideband"), `Application::Voip`, in-band FEC on, DTX off. A 20 ms
6//! frame is therefore 320 PCM samples in, one variable-size packet out.
7//!
8//! # Wire constants vs. audio reality
9//!
10//! Three numbers here look contradictory and are not:
11//!
12//! - The SDP rtpmap is always `opus/48000/2` and the RTP timestamp
13//! advances by [`OPUS_RTP_SAMPLES_PER_FRAME`] (960) per 20 ms packet.
14//! RFC 7587 §4.1 pins the RTP clock to 48 kHz and the channel count
15//! to 2 **regardless of what the codec actually does** — they are
16//! wire-format constants, not a description of the stream.
17//! - The PCM on either side of the codec is [`OPUS_PCM_SAMPLE_RATE`]
18//! (16 kHz) mono. That's the audio reality: wideband speech.
19//!
20//! Confusing the 48 kHz wire clock with the 16 kHz PCM rate is the
21//! classic Opus-over-RTP interop bug; the constants below exist so
22//! consumers never re-derive these numbers.
23//!
24//! # Loss recovery is driven by the consumer
25//!
26//! Unlike G.711, Opus can *recover* lost packets — but only if the
27//! receive path notices the loss (an RTP sequence-number gap) and asks:
28//!
29//! - [`OpusDecoder::decode_fec`] — recover the lost frame from the
30//! *next* packet's embedded redundancy (in-band FEC / LBRR). The
31//! encoder only embeds that redundancy because we set a nonzero
32//! expected packet loss; `set_inband_fec(true)` alone puts nothing
33//! on the wire.
34//! - [`OpusDecoder::conceal`] — packet-loss concealment when nothing
35//! usable arrived at all: the decoder extrapolates a plausible frame
36//! instead of emitting a click or silence.
37//!
38//! Opus lives in `wavekat-core` (not `wavekat-sip`) for the same reason
39//! G.711 does: codecs are a consumer-layer choice — `wavekat-sip`
40//! deliberately stays codec-agnostic.
41
42use crate::CoreError;
43use ::opus::{Application, Bitrate, Channels};
44
45/// De-facto default dynamic RTP payload type for Opus in SDP offers.
46///
47/// Unlike PCMU (0) / PCMA (8) this is **not** a static assignment —
48/// Opus rides the dynamic range (96–127) and the peer may pick any
49/// number. 111 is only what *we* offer; the negotiated value comes from
50/// SDP and must be carried per-call, never assumed.
51pub const OPUS_DEFAULT_PAYLOAD_TYPE: u8 = 111;
52
53/// The RTP timestamp clock rate for Opus — always 48 kHz per RFC 7587
54/// §4.1, independent of the actual encode rate. Wire constant.
55pub const OPUS_RTP_CLOCK_RATE: u32 = 48_000;
56
57/// RTP timestamp advance per 20 ms Opus packet: 20 ms at the mandatory
58/// 48 kHz wire clock. Feed this to the RTP sender's `samples_per_frame`
59/// (the G.711 equivalent is 160).
60pub const OPUS_RTP_SAMPLES_PER_FRAME: u32 = 960;
61
62/// PCM sample rate on both sides of the codec: 16 kHz wideband, the
63/// locked profile from doc 45. This — not 48 kHz — is the rate the
64/// microphone is resampled to before encode and the rate decoded frames
65/// come out at.
66pub const OPUS_PCM_SAMPLE_RATE: u32 = 16_000;
67
68/// PCM samples in one 20 ms frame at [`OPUS_PCM_SAMPLE_RATE`]: what
69/// [`OpusEncoder::encode`] consumes per call and what a normal 20 ms
70/// packet decodes to.
71pub const OPUS_FRAME_SAMPLES: usize = 320;
72
73/// PCM samples in the largest legal Opus frame (120 ms) at
74/// [`OPUS_PCM_SAMPLE_RATE`]. The far end chooses its own frame size, so
75/// decode buffers must size for this, not for 20 ms.
76pub const OPUS_MAX_FRAME_SAMPLES: usize = 1920;
77
78/// Target bitrate: the middle of the 24–32 kbps wideband-voice sweet
79/// spot from doc 45 — a large quality jump over G.711's 64 kbps
80/// narrowband at under half the bandwidth.
81pub const OPUS_DEFAULT_BITRATE: i32 = 28_000;
82
83/// Expected packet-loss percentage told to the encoder. Nonzero is what
84/// makes libopus actually spend bits on in-band FEC redundancy.
85pub const OPUS_DEFAULT_PACKET_LOSS_PERC: i32 = 10;
86
87/// Scratch headroom for one encoded packet. A 20 ms mono voice frame at
88/// our bitrate is a few hundred bytes at most; the theoretical Opus
89/// maximum for one frame is 1275.
90const MAX_PACKET_BYTES: usize = 1500;
91
92fn opus_err(op: &str, e: ::opus::Error) -> CoreError {
93 CoreError::Audio(format!("opus {op}: {e}"))
94}
95
96/// Stateful Opus encoder with the fixed WaveKat voice profile.
97///
98/// Unlike [`G711Codec`](super::g711::G711Codec) this is not `Copy` —
99/// libopus carries inter-frame prediction state, so one encoder serves
100/// exactly one outbound stream and must be fed every frame in order.
101pub struct OpusEncoder {
102 inner: ::opus::Encoder,
103}
104
105impl OpusEncoder {
106 /// Create an encoder with the locked profile: 16 kHz mono VoIP,
107 /// ~28 kbps, in-band FEC on (with expected loss set so it actually
108 /// engages), DTX off.
109 pub fn new() -> Result<Self, CoreError> {
110 let mut inner =
111 ::opus::Encoder::new(OPUS_PCM_SAMPLE_RATE, Channels::Mono, Application::Voip)
112 .map_err(|e| opus_err("encoder create", e))?;
113 inner
114 .set_bitrate(Bitrate::Bits(OPUS_DEFAULT_BITRATE))
115 .map_err(|e| opus_err("set_bitrate", e))?;
116 inner
117 .set_inband_fec(true)
118 .map_err(|e| opus_err("set_inband_fec", e))?;
119 inner
120 .set_packet_loss_perc(OPUS_DEFAULT_PACKET_LOSS_PERC)
121 .map_err(|e| opus_err("set_packet_loss_perc", e))?;
122 inner.set_dtx(false).map_err(|e| opus_err("set_dtx", e))?;
123 Ok(Self { inner })
124 }
125
126 /// Encode exactly one 20 ms frame ([`OPUS_FRAME_SAMPLES`] samples at
127 /// [`OPUS_PCM_SAMPLE_RATE`]) into one Opus packet. Appends the packet
128 /// bytes to `out` and returns the packet length.
129 ///
130 /// The length is enforced because libopus only accepts legal frame
131 /// durations and the WaveKat send path packetizes at 20 ms; a wrong
132 /// slice length here is always a caller bug, not a stream condition.
133 pub fn encode(&mut self, pcm: &[i16], out: &mut Vec<u8>) -> Result<usize, CoreError> {
134 if pcm.len() != OPUS_FRAME_SAMPLES {
135 return Err(CoreError::Audio(format!(
136 "opus encode: expected one 20 ms frame ({OPUS_FRAME_SAMPLES} samples), got {}",
137 pcm.len()
138 )));
139 }
140 let start = out.len();
141 out.resize(start + MAX_PACKET_BYTES, 0);
142 let n = self
143 .inner
144 .encode(pcm, &mut out[start..])
145 .map_err(|e| opus_err("encode", e))?;
146 out.truncate(start + n);
147 Ok(n)
148 }
149}
150
151/// Stateful Opus decoder for one inbound stream.
152///
153/// Also not `Copy` — the decoder state is what makes
154/// [`conceal`](Self::conceal) and [`decode_fec`](Self::decode_fec)
155/// possible, so it must see every packet of its stream in order.
156pub struct OpusDecoder {
157 inner: ::opus::Decoder,
158}
159
160impl OpusDecoder {
161 /// Create a decoder producing 16 kHz mono PCM.
162 pub fn new() -> Result<Self, CoreError> {
163 let inner = ::opus::Decoder::new(OPUS_PCM_SAMPLE_RATE, Channels::Mono)
164 .map_err(|e| opus_err("decoder create", e))?;
165 Ok(Self { inner })
166 }
167
168 /// Decode one received Opus packet. Appends the PCM samples to `out`
169 /// and returns how many were produced.
170 ///
171 /// The count is normally [`OPUS_FRAME_SAMPLES`] but the far end
172 /// chooses its own frame size — up to [`OPUS_MAX_FRAME_SAMPLES`] —
173 /// so consumers must use the returned length, not assume 20 ms.
174 pub fn decode(&mut self, payload: &[u8], out: &mut Vec<i16>) -> Result<usize, CoreError> {
175 self.decode_inner(payload, out, false)
176 }
177
178 /// Recover a **lost** frame from the packet that followed it.
179 ///
180 /// Call this when a sequence gap shows exactly one packet was lost:
181 /// pass the packet *after* the gap, and the decoder reconstructs the
182 /// missing frame from that packet's embedded redundancy (falling
183 /// back to concealment when the sender embedded none). Then decode
184 /// the passed packet normally with [`decode`](Self::decode) — this
185 /// call consumes only its redundancy, not its own frame.
186 pub fn decode_fec(
187 &mut self,
188 next_payload: &[u8],
189 out: &mut Vec<i16>,
190 ) -> Result<usize, CoreError> {
191 self.decode_inner(next_payload, out, true)
192 }
193
194 /// Produce one concealment frame for a packet that never arrived and
195 /// can't be recovered (no follow-up packet yet, or a multi-packet
196 /// gap). The decoder extrapolates from what it last heard, which
197 /// sounds far better than a gap of silence.
198 pub fn conceal(&mut self, out: &mut Vec<i16>) -> Result<usize, CoreError> {
199 // An empty input slice is the libopus idiom for "the packet was
200 // lost" — the decoder synthesizes `out.len()` samples of PLC. We
201 // ask for one nominal 20 ms frame per lost packet.
202 let start = out.len();
203 out.resize(start + OPUS_FRAME_SAMPLES, 0);
204 let n = self
205 .inner
206 .decode(&[], &mut out[start..], false)
207 .map_err(|e| opus_err("plc decode", e))?;
208 out.truncate(start + n);
209 Ok(n)
210 }
211
212 fn decode_inner(
213 &mut self,
214 payload: &[u8],
215 out: &mut Vec<i16>,
216 fec: bool,
217 ) -> Result<usize, CoreError> {
218 let start = out.len();
219 if fec {
220 // FEC reconstruction is duration-driven: the output slice
221 // length tells libopus how much audio to recover. The
222 // redundancy in a packet mirrors that packet's own frame
223 // duration, so size the request from the packet itself.
224 let lost = ::opus::packet::get_nb_samples(payload, OPUS_PCM_SAMPLE_RATE)
225 .map_err(|e| opus_err("fec frame sizing", e))?;
226 out.resize(start + lost, 0);
227 } else {
228 out.resize(start + OPUS_MAX_FRAME_SAMPLES, 0);
229 }
230 let n = self
231 .inner
232 .decode(payload, &mut out[start..], fec)
233 .map_err(|e| opus_err(if fec { "fec decode" } else { "decode" }, e))?;
234 out.truncate(start + n);
235 Ok(n)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn sine_frame(freq: f32, amplitude: f32, offset: usize) -> Vec<i16> {
244 (0..OPUS_FRAME_SAMPLES)
245 .map(|i| {
246 let t = (offset + i) as f32 / OPUS_PCM_SAMPLE_RATE as f32;
247 ((t * freq * 2.0 * std::f32::consts::PI).sin() * amplitude) as i16
248 })
249 .collect()
250 }
251
252 fn rms(pcm: &[i16]) -> f64 {
253 if pcm.is_empty() {
254 return 0.0;
255 }
256 let sum: f64 = pcm.iter().map(|&s| (s as f64) * (s as f64)).sum();
257 (sum / pcm.len() as f64).sqrt()
258 }
259
260 #[test]
261 fn constants_match_rfc7587_and_the_locked_profile() {
262 // RFC 7587 §4.1 pins the wire clock; doc 45 pins the profile.
263 // Hard-coding them in a test protects SDP/RTP interop against a
264 // casual "cleanup" the same way the G.711 constants test does.
265 assert_eq!(OPUS_DEFAULT_PAYLOAD_TYPE, 111);
266 assert_eq!(OPUS_RTP_CLOCK_RATE, 48_000);
267 assert_eq!(OPUS_RTP_SAMPLES_PER_FRAME, 960);
268 assert_eq!(OPUS_PCM_SAMPLE_RATE, 16_000);
269 assert_eq!(OPUS_FRAME_SAMPLES, 320);
270 assert_eq!(OPUS_MAX_FRAME_SAMPLES, 1920);
271 // 20 ms at each clock, cross-checked.
272 assert_eq!(OPUS_RTP_SAMPLES_PER_FRAME, OPUS_RTP_CLOCK_RATE / 50);
273 assert_eq!(OPUS_FRAME_SAMPLES as u32, OPUS_PCM_SAMPLE_RATE / 50);
274 }
275
276 #[test]
277 fn encode_decode_round_trip_preserves_signal_energy() {
278 let mut enc = OpusEncoder::new().unwrap();
279 let mut dec = OpusDecoder::new().unwrap();
280
281 let mut input_rms = 0.0;
282 let mut decoded_tail = Vec::new();
283 for i in 0..20 {
284 let frame = sine_frame(440.0, 8000.0, i * OPUS_FRAME_SAMPLES);
285 input_rms = rms(&frame);
286
287 let mut packet = Vec::new();
288 let n = enc.encode(&frame, &mut packet).unwrap();
289 assert!(n > 0 && n <= 1275, "packet size {n} out of range");
290 assert_eq!(packet.len(), n);
291
292 let mut pcm = Vec::new();
293 let produced = dec.decode(&packet, &mut pcm).unwrap();
294 assert_eq!(produced, OPUS_FRAME_SAMPLES);
295 assert_eq!(pcm.len(), OPUS_FRAME_SAMPLES);
296 // Skip the first frames: codec lookahead delay means the
297 // start of the stream decodes quiet. Steady state is what
298 // matters.
299 if i >= 5 {
300 decoded_tail.extend_from_slice(&pcm);
301 }
302 }
303
304 let out_rms = rms(&decoded_tail);
305 assert!(
306 (out_rms - input_rms).abs() / input_rms < 0.5,
307 "round-trip energy drifted too far: in {input_rms:.0}, out {out_rms:.0}"
308 );
309 }
310
311 #[test]
312 fn encoder_profile_is_the_locked_one() {
313 // FEC on, expected loss nonzero (without it FEC is silent), DTX
314 // off — the doc-45 profile, read back through libopus getters so
315 // a dropped setter call fails loudly.
316 let mut enc = OpusEncoder::new().unwrap();
317 assert!(enc.inner.get_inband_fec().unwrap());
318 assert_eq!(
319 enc.inner.get_packet_loss_perc().unwrap(),
320 OPUS_DEFAULT_PACKET_LOSS_PERC
321 );
322 assert!(!enc.inner.get_dtx().unwrap());
323 assert_eq!(
324 enc.inner.get_bitrate().unwrap(),
325 Bitrate::Bits(OPUS_DEFAULT_BITRATE)
326 );
327 }
328
329 #[test]
330 fn encode_rejects_non_20ms_input() {
331 let mut enc = OpusEncoder::new().unwrap();
332 let mut out = Vec::new();
333 assert!(enc.encode(&[0i16; 160], &mut out).is_err());
334 assert!(enc.encode(&[0i16; 321], &mut out).is_err());
335 assert!(out.is_empty(), "failed encode must not write output");
336 }
337
338 #[test]
339 fn encode_appends_rather_than_replacing() {
340 // Same buffer-reuse contract as G711Codec::encode.
341 let mut enc = OpusEncoder::new().unwrap();
342 let mut out = vec![0xAAu8, 0xBB];
343 let frame = sine_frame(440.0, 8000.0, 0);
344 let n = enc.encode(&frame, &mut out).unwrap();
345 assert_eq!(out.len(), 2 + n);
346 assert_eq!(&out[..2], &[0xAA, 0xBB]);
347 }
348
349 // Encode a stream that is near-silent except for one loud frame,
350 // "lose" the loud frame, and try to recover it from the next
351 // packet. With FEC engaged the recovery carries the loud frame's
352 // energy; without FEC the decoder can only extrapolate from the
353 // silent context and produces near-silence. Amplitude is the
354 // discriminator because it survives codec coloration.
355 fn lost_loud_frame_recovery_rms(fec: bool) -> f64 {
356 let mut enc = if fec {
357 OpusEncoder::new().unwrap()
358 } else {
359 let mut inner =
360 ::opus::Encoder::new(OPUS_PCM_SAMPLE_RATE, Channels::Mono, Application::Voip)
361 .unwrap();
362 inner
363 .set_bitrate(Bitrate::Bits(OPUS_DEFAULT_BITRATE))
364 .unwrap();
365 inner.set_inband_fec(false).unwrap();
366 OpusEncoder { inner }
367 };
368 let mut dec = OpusDecoder::new().unwrap();
369
370 const LOUD: usize = 6;
371 let mut packets = Vec::new();
372 for i in 0..=LOUD + 1 {
373 let frame = if i == LOUD {
374 sine_frame(440.0, 12000.0, i * OPUS_FRAME_SAMPLES)
375 } else {
376 sine_frame(440.0, 30.0, i * OPUS_FRAME_SAMPLES)
377 };
378 let mut packet = Vec::new();
379 enc.encode(&frame, &mut packet).unwrap();
380 packets.push(packet);
381 }
382
383 let mut pcm = Vec::new();
384 for packet in &packets[..LOUD] {
385 pcm.clear();
386 dec.decode(packet, &mut pcm).unwrap();
387 }
388 // Packet LOUD never arrives; recover it from packet LOUD+1.
389 let mut recovered = Vec::new();
390 let n = dec.decode_fec(&packets[LOUD + 1], &mut recovered).unwrap();
391 assert_eq!(n, OPUS_FRAME_SAMPLES);
392 rms(&recovered)
393 }
394
395 #[test]
396 fn fec_actually_recovers_a_lost_frame() {
397 let with_fec = lost_loud_frame_recovery_rms(true);
398 let without_fec = lost_loud_frame_recovery_rms(false);
399 // The FEC recovery must carry real signal, and beat the
400 // conceal-from-silence baseline by a wide margin — this is the
401 // "FEC engages on the wire" proof, not just an API smoke test.
402 assert!(
403 with_fec > 1000.0,
404 "FEC recovery is near-silent (rms {with_fec:.1}) — redundancy not embedded?"
405 );
406 assert!(
407 with_fec > without_fec * 4.0,
408 "FEC recovery ({with_fec:.1}) not clearly better than PLC baseline ({without_fec:.1})"
409 );
410 }
411
412 #[test]
413 fn conceal_produces_a_plausible_frame_after_loss() {
414 let mut enc = OpusEncoder::new().unwrap();
415 let mut dec = OpusDecoder::new().unwrap();
416
417 let mut pcm = Vec::new();
418 for i in 0..10 {
419 let frame = sine_frame(440.0, 8000.0, i * OPUS_FRAME_SAMPLES);
420 let mut packet = Vec::new();
421 enc.encode(&frame, &mut packet).unwrap();
422 pcm.clear();
423 dec.decode(&packet, &mut pcm).unwrap();
424 }
425
426 let mut concealed = Vec::new();
427 let n = dec.conceal(&mut concealed).unwrap();
428 assert_eq!(n, OPUS_FRAME_SAMPLES);
429 // PLC extrapolates the ongoing tone — it must be audible, not a
430 // silence gap.
431 assert!(
432 rms(&concealed) > 500.0,
433 "concealment is near-silent (rms {:.1})",
434 rms(&concealed)
435 );
436 }
437
438 #[test]
439 fn decode_handles_frames_larger_than_20ms() {
440 // The far end picks its own frame size. Build a 40 ms packet
441 // with a raw libopus encoder and make sure our decoder sizes for
442 // it instead of assuming 20 ms.
443 let mut enc =
444 ::opus::Encoder::new(OPUS_PCM_SAMPLE_RATE, Channels::Mono, Application::Voip).unwrap();
445 let frame: Vec<i16> = (0..OPUS_FRAME_SAMPLES * 2)
446 .map(|i| {
447 let t = i as f32 / OPUS_PCM_SAMPLE_RATE as f32;
448 ((t * 440.0 * 2.0 * std::f32::consts::PI).sin() * 8000.0) as i16
449 })
450 .collect();
451 let packet = enc.encode_vec(&frame, MAX_PACKET_BYTES).unwrap();
452
453 let mut dec = OpusDecoder::new().unwrap();
454 let mut pcm = Vec::new();
455 let n = dec.decode(&packet, &mut pcm).unwrap();
456 assert_eq!(n, OPUS_FRAME_SAMPLES * 2, "40 ms frame must decode fully");
457 assert_eq!(pcm.len(), n);
458 }
459
460 #[test]
461 fn decode_rejects_garbage() {
462 let mut dec = OpusDecoder::new().unwrap();
463 let mut pcm = Vec::new();
464 // An invalid TOC byte sequence must surface an error, not panic
465 // or emit junk audio.
466 let garbage = vec![0xFFu8; 3];
467 assert!(dec.decode(&garbage, &mut pcm).is_err());
468 }
469}