Skip to main content

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}