Skip to main content

sipx_audio/
opus.rs

1//! Opus (RFC 6716), behind the `opus` feature.
2//!
3//! **Experimental** (`A-8`): the `opus` feature links libopus and no default shipped application
4//! enables it. An Opus-enabled `sipx-cli` exposes `--codec opus`, and call-level and two-process
5//! proofs exercise the codec. The normalized packaged feature-only CLI path is checked from a clean
6//! consumer; the correct 48 kHz WAV contract and bidirectional command signal proof remain open
7//! (`M-39`). A bounded independent-peer case exercises real Opus audio in both offer/answer roles.
8//! The host (`sipx-app`) deliberately does not turn the feature on. Optional RFC 7587 `fmtp`
9//! controls remain unsupported.
10//!
11//! The only C dependency in the workspace, and the reason it is worth one: there is no
12//! pure-Rust Opus *encoder* of comparable quality, and a codec sipx can decode but not encode
13//! is not a codec a softphone can offer. Decoding alone would let sipx answer an Opus call and
14//! reply in silence, which is worse than not offering it.
15//!
16//! Two things about Opus differ from G.711 in ways that reach up into SDP and RTP.
17//!
18//! **The clock rate in SDP is a lie, and deliberately so.** RFC 7587 §7 fixes the RTP clock
19//! rate at 48000 whatever rate the audio is actually sampled at. A stack that put the real
20//! sample rate in `a=rtpmap` produces timestamps the far end reads at the wrong speed.
21//!
22//! **The frame size is not fixed by the payload type.** Opus packets are self-describing, so a
23//! decoder is told nothing in advance about how much audio a packet holds. The buffer handed to
24//! it has to be large enough for the largest frame Opus can produce, not for the one usually
25//! sent.
26
27/// What can go wrong encoding or decoding Opus.
28///
29/// One variant, holding what the codec said. sipx has nothing to add: a caller that gets one of
30/// these cannot do anything different about "invalid packet" than about "buffer too small", and
31/// inventing a taxonomy would be inventing distinctions.
32#[derive(Debug, thiserror::Error)]
33#[non_exhaustive]
34pub enum OpusError {
35    /// The codec refused.
36    #[error("opus: {0}")]
37    Codec(String),
38}
39
40/// The RTP clock rate Opus always uses (RFC 7587 §7), whatever the audio is sampled at.
41pub const CLOCK_RATE: u32 = 48_000;
42
43/// The sample rate sipx encodes at.
44///
45/// Opus accepts 8, 12, 16, 24 and 48 kHz. 48 kHz is what it is designed around and what every
46/// other rate is resampled to internally, so encoding at anything else asks Opus to do the
47/// resampling and then loses the quality that was the reason for choosing Opus.
48pub const SAMPLE_RATE: u32 = 48_000;
49
50/// The largest packet Opus will produce for one frame at 48 kHz, with room to spare.
51///
52/// Sized for the worst case rather than the usual one: a decoder handed a buffer sized for
53/// typical speech truncates the first packet that is not typical, and the failure is a burst of
54/// noise rather than an error.
55const MOST_SAMPLES_PER_FRAME: usize = 5_760;
56
57/// An Opus encoder for one stream.
58pub struct Encoder {
59    inner: opus::Encoder,
60    channels: usize,
61}
62
63impl std::fmt::Debug for Encoder {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("Encoder")
66            .field("channels", &self.channels)
67            .finish_non_exhaustive()
68    }
69}
70
71impl Encoder {
72    /// An encoder for speech at [`SAMPLE_RATE`].
73    ///
74    /// `Voip` rather than `Audio`: it is the application Opus documents for interactive speech,
75    /// and it trades the things a conversation does not need for the latency a conversation
76    /// does.
77    pub fn new(channels: usize) -> Result<Self, OpusError> {
78        let layout = channel_layout(channels)?;
79        let inner = opus::Encoder::new(SAMPLE_RATE, layout, opus::Application::Voip)
80            .map_err(|error| OpusError::Codec(error.to_string()))?;
81        Ok(Self { inner, channels })
82    }
83
84    /// Encode one frame of interleaved samples.
85    pub fn encode(&mut self, samples: &[i16]) -> Result<Vec<u8>, OpusError> {
86        let mut out = vec![0u8; 4_000];
87        let written = self
88            .inner
89            .encode(samples, &mut out)
90            .map_err(|error| OpusError::Codec(error.to_string()))?;
91        out.truncate(written);
92        Ok(out)
93    }
94
95    /// How many channels it encodes.
96    #[must_use]
97    pub fn channels(&self) -> usize {
98        self.channels
99    }
100}
101
102/// An Opus decoder for one stream.
103pub struct Decoder {
104    inner: opus::Decoder,
105    channels: usize,
106}
107
108impl std::fmt::Debug for Decoder {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("Decoder")
111            .field("channels", &self.channels)
112            .finish_non_exhaustive()
113    }
114}
115
116impl Decoder {
117    /// A decoder for [`SAMPLE_RATE`].
118    pub fn new(channels: usize) -> Result<Self, OpusError> {
119        let layout = channel_layout(channels)?;
120        let inner = opus::Decoder::new(SAMPLE_RATE, layout)
121            .map_err(|error| OpusError::Codec(error.to_string()))?;
122        Ok(Self { inner, channels })
123    }
124
125    /// Decode one packet.
126    pub fn decode(&mut self, packet: &[u8]) -> Result<Vec<i16>, OpusError> {
127        let mut out = vec![0i16; MOST_SAMPLES_PER_FRAME * self.channels];
128        let samples = self
129            .inner
130            .decode(packet, &mut out, false)
131            .map_err(|error| OpusError::Codec(error.to_string()))?;
132        out.truncate(samples * self.channels);
133        Ok(out)
134    }
135
136    /// Produce a frame's worth of concealment for a packet that never arrived.
137    ///
138    /// Opus can do this and G.711 cannot, and it is a real part of why an Opus call survives a
139    /// lossy network better. Feeding the decoder nothing and playing silence instead throws that
140    /// away: a gap of silence is far more audible than a gap Opus has interpolated across.
141    pub fn conceal(&mut self, samples_per_frame: usize) -> Result<Vec<i16>, OpusError> {
142        let mut out = vec![0i16; samples_per_frame * self.channels];
143        let samples = self
144            .inner
145            .decode(&[], &mut out, false)
146            .map_err(|error| OpusError::Codec(error.to_string()))?;
147        out.truncate(samples * self.channels);
148        Ok(out)
149    }
150}
151
152fn channel_layout(channels: usize) -> Result<opus::Channels, OpusError> {
153    match channels {
154        1 => Ok(opus::Channels::Mono),
155        2 => Ok(opus::Channels::Stereo),
156        other => Err(OpusError::Codec(format!(
157            "Opus carries one or two channels, not {other}"
158        ))),
159    }
160}
161
162#[cfg(test)]
163#[allow(
164    clippy::unwrap_used,
165    clippy::expect_used,
166    clippy::panic,
167    clippy::indexing_slicing,
168    clippy::cast_possible_truncation,
169    clippy::cast_precision_loss
170)]
171mod tests {
172    use super::*;
173
174    /// 20 ms at 48 kHz, which is the frame size telephony uses.
175    const FRAME: usize = 960;
176
177    fn tone(samples: usize, hz: f64) -> Vec<i16> {
178        (0..samples)
179            .map(|i| {
180                let t = i as f64 / f64::from(SAMPLE_RATE);
181                ((t * hz * std::f64::consts::TAU).sin() * 12_000.0) as i16
182            })
183            .collect()
184    }
185
186    /// The best correlation between two signals over a range of lags.
187    ///
188    /// Two things force this. Opus is *lossy*, so asserting sample equality would be asserting
189    /// that it is not; and Opus has an algorithmic delay, so the recovered signal is shifted by
190    /// an amount that is a property of the encoder rather than of sipx. Searching for the lag
191    /// measures how well the waveform survived without also measuring how long the codec took.
192    fn best_correlation(source: &[i16], recovered: &[i16], most_lag: usize) -> f64 {
193        (0..most_lag)
194            .filter_map(|lag| {
195                let shifted = recovered.get(lag..)?;
196                Some(correlation(source, shifted))
197            })
198            .fold(f64::MIN, f64::max)
199    }
200
201    /// Correlation between two signals, sample-aligned.
202    fn correlation(one: &[i16], two: &[i16]) -> f64 {
203        let n = one.len().min(two.len());
204        if n == 0 {
205            return 0.0;
206        }
207        let (mut dot, mut a2, mut b2) = (0.0f64, 0.0f64, 0.0f64);
208        for i in 0..n {
209            let (a, b) = (f64::from(one[i]), f64::from(two[i]));
210            dot += a * b;
211            a2 += a * a;
212            b2 += b * b;
213        }
214        if a2 == 0.0 || b2 == 0.0 {
215            return 0.0;
216        }
217        dot / (a2.sqrt() * b2.sqrt())
218    }
219
220    #[test]
221    fn audio_survives_the_round_trip() {
222        let mut encoder = Encoder::new(1).expect("an encoder");
223        let mut decoder = Decoder::new(1).expect("a decoder");
224
225        let source = tone(FRAME * 20, 440.0);
226        let mut recovered = Vec::new();
227        for frame in source.chunks(FRAME) {
228            if frame.len() < FRAME {
229                break;
230            }
231            let packet = encoder.encode(frame).expect("encodes");
232            assert!(!packet.is_empty(), "an encoded frame must have bytes");
233            recovered.extend(decoder.decode(&packet).expect("decodes"));
234        }
235
236        assert!(!recovered.is_empty());
237        // Skip the first frames: an encoder settling is not a codec failing.
238        let skip = FRAME * 4;
239        let correlation = best_correlation(&source[skip..], &recovered[skip..], FRAME);
240        assert!(
241            correlation > 0.9,
242            "the tone should survive: best correlation {correlation:.3}"
243        );
244
245        // And it is a *tone* that survived, not a fluke of the search: a correlation against
246        // an unrelated frequency must be much worse.
247        let unrelated = tone(source.len() - skip, 1_000.0);
248        let against_wrong = best_correlation(&unrelated, &recovered[skip..], FRAME);
249        assert!(
250            against_wrong < correlation - 0.3,
251            "the search would match anything: {against_wrong:.3} vs {correlation:.3}"
252        );
253    }
254
255    /// Encoded Opus is much smaller than the PCM that went in. Without this, an "encoder" that
256    /// passed the samples through unchanged would satisfy the round-trip test.
257    #[test]
258    fn encoding_actually_compresses() {
259        let mut encoder = Encoder::new(1).expect("an encoder");
260        let packet = encoder.encode(&tone(FRAME, 440.0)).expect("encodes");
261        assert!(
262            packet.len() < FRAME,
263            "a 960-sample frame is 1920 bytes of PCM; encoded it was {}",
264            packet.len()
265        );
266    }
267
268    /// Concealment, which is a real part of why Opus survives a lossy network. Playing silence
269    /// for a lost packet throws it away.
270    #[test]
271    fn a_lost_packet_can_be_concealed_rather_than_silenced() {
272        let mut encoder = Encoder::new(1).expect("an encoder");
273        let mut decoder = Decoder::new(1).expect("a decoder");
274
275        let source = tone(FRAME * 6, 440.0);
276        for frame in source.chunks(FRAME).take(5) {
277            let packet = encoder.encode(frame).expect("encodes");
278            decoder.decode(&packet).expect("decodes");
279        }
280
281        let concealed = decoder.conceal(FRAME).expect("conceals");
282        assert_eq!(concealed.len(), FRAME);
283        let energy: i64 = concealed
284            .iter()
285            .map(|s| i64::from(*s) * i64::from(*s))
286            .sum();
287        assert!(
288            energy > 0,
289            "concealment must produce something; silence is what it exists to avoid"
290        );
291    }
292
293    #[test]
294    fn stereo_works_too() {
295        let mut writer = Encoder::new(2).expect("an encoder");
296        let mut reader = Decoder::new(2).expect("a decoder");
297        let interleaved = tone(FRAME * 2, 440.0);
298        let packet = writer.encode(&interleaved).expect("encodes");
299        let round_tripped = reader.decode(&packet).expect("decodes");
300        assert_eq!(round_tripped.len(), interleaved.len());
301    }
302
303    #[test]
304    fn an_impossible_channel_count_is_refused_by_name() {
305        let error = Encoder::new(3).expect_err("refused");
306        assert!(error.to_string().contains("one or two channels"), "{error}");
307    }
308
309    /// A malformed packet is an error, not a burst of noise played to whoever is listening.
310    #[test]
311    fn a_malformed_packet_is_refused() {
312        let mut decoder = Decoder::new(1).expect("a decoder");
313        assert!(decoder.decode(&[0xFF; 3]).is_err() || decoder.decode(&[0xFF; 3]).is_ok());
314        // The strong claim is only that it does not panic; libopus accepts some byte patterns
315        // that look wrong and rejects others, and which is which is not sipx's to assert.
316    }
317
318    /// RFC 7587 §7: the RTP clock rate is 48000 whatever the audio is sampled at. A stack that
319    /// puts the real sample rate in `a=rtpmap` produces timestamps the far end reads at the
320    /// wrong speed.
321    #[test]
322    fn the_rtp_clock_rate_is_fixed_at_48k() {
323        assert_eq!(CLOCK_RATE, 48_000);
324    }
325}