Skip to main content

sipx_call/
media_policy.rs

1//! Application choices for an initial call's media.
2//!
3//! This module names policy only. SDP construction, offer/answer matching, ICE gathering and
4//! media startup stay in [`crate::call`], so a command-line caller and a library caller cannot
5//! acquire two implementations of negotiation.
6
7use std::net::{IpAddr, SocketAddr};
8
9use sipx_media::Codec;
10use sipx_media::ice::Gathering;
11use sipx_sdp::Capabilities;
12use sipx_sdp::ice::Credentials as IceCredentials;
13
14use crate::call::token;
15use crate::error::{Error, Result};
16
17/// One codec an application may put in its ordered preference list.
18///
19/// `Opus` remains a value in builds without the feature so configuration can fail with a typed
20/// setup error instead of treating a known codec name as an unknown string. It cannot enter a
21/// [`Codecs`] value in that build.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum CodecPreference {
25    /// G.711 µ-law (RFC 3551 §4.5.14).
26    Pcmu,
27    /// G.711 A-law (RFC 3551 §4.5.14).
28    Pcma,
29    /// Opus (RFC 6716, carried per RFC 7587).
30    Opus,
31    /// Mono signed linear PCM (RFC 3551 §4.5.11).
32    L16,
33}
34
35impl CodecPreference {
36    /// The stable configuration and result token.
37    #[must_use]
38    pub const fn name(self) -> &'static str {
39        match self {
40            Self::Pcmu => "pcmu",
41            Self::Pcma => "pcma",
42            Self::Opus => "opus",
43            Self::L16 => "l16",
44        }
45    }
46}
47
48/// Why an ordered codec selection cannot be honoured.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
50#[non_exhaustive]
51pub enum CodecSelectionError {
52    /// At least one audio codec must be offered.
53    #[error("at least one codec must be selected")]
54    Empty,
55    /// Repeating a codec does not express a second preference.
56    #[error("codec `{0}` was selected more than once")]
57    Duplicate(&'static str),
58    /// The value is known, but this build cannot run it.
59    #[error("codec `opus` requires a build with the `opus` feature")]
60    OpusUnavailable,
61}
62
63/// Which codecs a call offers and accepts, in preference order (`M-30`, `P-9`).
64///
65/// The default is PCMU then PCMA. An explicit selection is exactly the ordered set supplied by
66/// the application; negotiation may choose no codec outside it. RFC 4733 telephone events remain
67/// alongside every non-empty audio set and are not themselves an audio codec.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Codecs {
70    ordered: [Option<CodecPreference>; 4],
71}
72
73impl Default for Codecs {
74    fn default() -> Self {
75        Self::G711
76    }
77}
78
79impl Codecs {
80    /// The compatibility default: PCMU, then PCMA.
81    pub const G711: Self = Self {
82        ordered: [
83            Some(CodecPreference::Pcmu),
84            Some(CodecPreference::Pcma),
85            None,
86            None,
87        ],
88    };
89
90    /// Opus first, followed by the compatibility G.711 pair.
91    #[cfg(feature = "opus")]
92    #[allow(
93        non_upper_case_globals,
94        reason = "preserves the pre-P-9 public spelling"
95    )]
96    pub const Opus: Self = Self {
97        ordered: [
98            Some(CodecPreference::Opus),
99            Some(CodecPreference::Pcmu),
100            Some(CodecPreference::Pcma),
101            None,
102        ],
103    };
104
105    /// Mono L16, in its static 44.1 kHz and dynamic 8 kHz forms.
106    pub const L16: Self = Self {
107        ordered: [Some(CodecPreference::L16), None, None, None],
108    };
109
110    /// Validate an application's exact ordered preference list.
111    ///
112    /// # Errors
113    ///
114    /// Empty lists, duplicates and Opus in a build without Opus are refused before a call can
115    /// bind media or send signalling.
116    pub fn ordered(
117        preferences: &[CodecPreference],
118    ) -> std::result::Result<Self, CodecSelectionError> {
119        if preferences.is_empty() {
120            return Err(CodecSelectionError::Empty);
121        }
122        let mut ordered = [None; 4];
123        for (slot, preference) in ordered.iter_mut().zip(preferences.iter().copied()) {
124            if preferences
125                .iter()
126                .filter(|candidate| **candidate == preference)
127                .count()
128                > 1
129            {
130                return Err(CodecSelectionError::Duplicate(preference.name()));
131            }
132            if preference == CodecPreference::Opus && !cfg!(feature = "opus") {
133                return Err(CodecSelectionError::OpusUnavailable);
134            }
135            *slot = Some(preference);
136        }
137        // There are exactly four closed values, so a longer duplicate-free list cannot exist.
138        Ok(Self { ordered })
139    }
140
141    /// The selected codecs, in preference order.
142    pub fn preferences(self) -> impl Iterator<Item = CodecPreference> {
143        self.ordered.into_iter().flatten()
144    }
145
146    /// What this side offers or answers with.
147    pub(crate) fn capabilities(self, address: IpAddr, audio_port: u16) -> Capabilities {
148        let mut capabilities = Capabilities::g711(address, audio_port);
149        capabilities.audio_formats.clear();
150        capabilities.rtpmaps.clear();
151        for preference in self.preferences() {
152            match preference {
153                CodecPreference::Pcmu => {
154                    capabilities.audio_formats.push("0".to_owned());
155                    capabilities
156                        .rtpmaps
157                        .push(("0".to_owned(), "PCMU/8000".to_owned()));
158                }
159                CodecPreference::Pcma => {
160                    capabilities.audio_formats.push("8".to_owned());
161                    capabilities
162                        .rtpmaps
163                        .push(("8".to_owned(), "PCMA/8000".to_owned()));
164                }
165                CodecPreference::Opus => {
166                    // `ordered` refuses this value when the codec implementation is absent.
167                    capabilities.audio_formats.push("111".to_owned());
168                    capabilities
169                        .rtpmaps
170                        .push(("111".to_owned(), "opus/48000/2".to_owned()));
171                }
172                CodecPreference::L16 => {
173                    // RFC 3551 §6 assigns mono 44.1 kHz L16 statically to 11. The 8 kHz form is
174                    // a different format and therefore receives a dynamic number.
175                    capabilities.audio_formats.push("11".to_owned());
176                    capabilities
177                        .rtpmaps
178                        .push(("11".to_owned(), "L16/44100/1".to_owned()));
179                    capabilities.audio_formats.push("96".to_owned());
180                    capabilities
181                        .rtpmaps
182                        .push(("96".to_owned(), "L16/8000/1".to_owned()));
183                }
184            }
185        }
186        capabilities.audio_formats.push("101".to_owned());
187        capabilities
188            .rtpmaps
189            .push(("101".to_owned(), "telephone-event/8000".to_owned()));
190        capabilities
191    }
192
193    /// Whether this set carries a codec, so negotiation cannot settle outside the selection.
194    pub(crate) fn carries(self, codec: Codec) -> bool {
195        self.preferences().any(|preference| match preference {
196            CodecPreference::Pcmu => codec == Codec::Pcmu,
197            CodecPreference::Pcma => codec == Codec::Pcma,
198            #[cfg(feature = "opus")]
199            CodecPreference::Opus => codec == Codec::Opus,
200            #[cfg(not(feature = "opus"))]
201            CodecPreference::Opus => false,
202            CodecPreference::L16 => codec == Codec::L16,
203        })
204    }
205
206    /// Whether this selection carries the exact codec format, including its RTP clock.
207    pub(crate) fn carries_format(self, codec: Codec, clock_rate: u32) -> bool {
208        self.preferences().any(|preference| match preference {
209            CodecPreference::Pcmu => codec == Codec::Pcmu && clock_rate == 8_000,
210            CodecPreference::Pcma => codec == Codec::Pcma && clock_rate == 8_000,
211            #[cfg(feature = "opus")]
212            CodecPreference::Opus => codec == Codec::Opus && clock_rate == 48_000,
213            #[cfg(not(feature = "opus"))]
214            CodecPreference::Opus => false,
215            CodecPreference::L16 => codec == Codec::L16 && matches!(clock_rate, 8_000 | 44_100),
216        })
217    }
218}
219
220/// Whether an initial call exchange uses ICE (`docs/specs/ice.md` §13.4).
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum IcePolicy {
223    /// Emit no ICE attributes and start no connectivity-check worker.
224    #[default]
225    Disabled,
226    /// Gather host candidates from the bound media sockets.
227    Host,
228    /// Gather host candidates and ask this STUN server for server-reflexive candidates.
229    Stun(SocketAddr),
230}
231
232/// How the initial audio stream is keyed.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum Keying {
235    /// Preserve the compatibility behavior: SDES over protected signalling, plain RTP otherwise.
236    #[default]
237    Auto,
238    /// Require plain RTP, including when signalling is protected.
239    Plain,
240    /// Require SDES-SRTP and refuse an unprotected signalling path.
241    Sdes,
242    /// Require DTLS-SRTP; it never falls back to SDES or plain RTP.
243    DtlsSrtp,
244}
245
246/// The keying mechanism an established call actually uses.
247///
248/// Unlike [`Keying`], this contains no `Auto`: the compatibility policy has resolved to either
249/// plain RTP or SDES by the time a [`crate::Call`] exists.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum NegotiatedKeying {
252    /// Plain RTP, without media encryption.
253    Plain,
254    /// SDES-SRTP (RFC 4568).
255    Sdes,
256    /// DTLS-SRTP (RFC 5763 and RFC 5764).
257    DtlsSrtp,
258}
259
260/// A named composition of call/media requirements.
261///
262/// `Standard` preserves the independently selectable SIP media policies. `BrowserAudio` is the
263/// fail-closed one-stream profile in `docs/specs/webrtc-audio.md`; selecting it never authorizes
264/// fallback to the standard policy.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
266pub enum MediaProfile {
267    /// Ordinary SIP audio with independently selected codec, ICE, and keying policies.
268    #[default]
269    Standard,
270    /// Opus-first audio over WSS, ICE, DTLS-SRTP, and multiplexed RTCP.
271    BrowserAudio,
272}
273
274/// The media choices shared by dialing and answering a call.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
276pub struct MediaPolicy {
277    /// Named composition policy.
278    pub profile: MediaProfile,
279    /// Which codecs are offered and accepted.
280    pub codecs: Codecs,
281    /// Whether and how the initial exchange gathers ICE candidates.
282    pub ice: IcePolicy,
283    /// Which media keying mechanism the application selected.
284    pub keying: Keying,
285}
286
287impl MediaPolicy {
288    /// The fail-closed browser-audio policy.
289    ///
290    /// Feature availability and WSS are checked before media binding or gathering. The value is
291    /// constructible in every build so a missing Opus or DTLS feature produces a typed setup
292    /// error instead of turning a known profile name into an unknown configuration value.
293    #[must_use]
294    pub const fn browser_audio() -> Self {
295        #[cfg(feature = "opus")]
296        let codecs = Codecs::Opus;
297        #[cfg(not(feature = "opus"))]
298        let codecs = Codecs::G711;
299        Self {
300            profile: MediaProfile::BrowserAudio,
301            codecs,
302            ice: IcePolicy::Host,
303            keying: Keying::DtlsSrtp,
304        }
305    }
306
307    /// Select a named profile while retaining explicit lower-level choices.
308    ///
309    /// The browser-audio preflight still refuses any incompatible retained choice; this method
310    /// does not silently rewrite it.
311    #[must_use]
312    pub const fn with_profile(mut self, profile: MediaProfile) -> Self {
313        self.profile = profile;
314        self
315    }
316
317    /// Select a codec set while retaining the other media choices.
318    #[must_use]
319    pub const fn with_codecs(mut self, codecs: Codecs) -> Self {
320        self.codecs = codecs;
321        self
322    }
323
324    /// Select an ICE policy while retaining the other media choices.
325    #[must_use]
326    pub const fn with_ice(mut self, ice: IcePolicy) -> Self {
327        self.ice = ice;
328        self
329    }
330
331    /// Select the media keying while retaining the codec and ICE choices.
332    #[must_use]
333    pub const fn with_keying(mut self, keying: Keying) -> Self {
334        self.keying = keying;
335        self
336    }
337
338    /// Build fresh per-call gathering state when ICE was selected.
339    pub(crate) fn gathering(self, offerer: bool) -> Result<Option<Gathering>> {
340        if self.ice == IcePolicy::Disabled {
341            return Ok(None);
342        }
343        let credentials = IceCredentials::new(token(), format!("{}{}", token(), token()))
344            .ok_or_else(|| Error::Sdp("could not generate valid ICE credentials".to_owned()))?;
345        let mut gathering = Gathering::new(credentials, offerer);
346        if let IcePolicy::Stun(server) = self.ice {
347            gathering.stun_server = Some(server);
348        }
349        Ok(Some(gathering))
350    }
351}
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn the_default_remains_the_g711_pair_in_wire_order() {
360        assert_eq!(Codecs::default(), Codecs::G711);
361        assert_eq!(
362            Codecs::default().preferences().collect::<Vec<_>>(),
363            vec![CodecPreference::Pcmu, CodecPreference::Pcma]
364        );
365        let capabilities = Codecs::default().capabilities("192.0.2.9".parse().unwrap(), 40_000);
366        assert_eq!(capabilities.audio_formats, ["0", "8", "101"]);
367    }
368
369    #[test]
370    fn an_explicit_order_is_the_order_put_in_an_offer() {
371        let codecs = Codecs::ordered(&[CodecPreference::Pcma, CodecPreference::Pcmu]).unwrap();
372        let capabilities = codecs.capabilities("192.0.2.9".parse().unwrap(), 40_000);
373        assert_eq!(capabilities.audio_formats, ["8", "0", "101"]);
374        assert!(codecs.carries(Codec::Pcma));
375        assert!(codecs.carries(Codec::Pcmu));
376    }
377
378    /// M-43: one L16 preference names both mono formats sipx implements. Payload 11 is RFC
379    /// 3551's static 44.1 kHz assignment; the 8 kHz form needs an explicit dynamic mapping.
380    #[test]
381    fn l16_offers_the_static_and_dynamic_mono_formats() {
382        let codecs = Codecs::ordered(&[CodecPreference::L16]).unwrap();
383        let capabilities = codecs.capabilities("192.0.2.9".parse().unwrap(), 40_000);
384        assert_eq!(capabilities.audio_formats, ["11", "96", "101"]);
385        assert!(
386            capabilities
387                .rtpmaps
388                .contains(&("11".to_owned(), "L16/44100/1".to_owned()))
389        );
390        assert!(
391            capabilities
392                .rtpmaps
393                .contains(&("96".to_owned(), "L16/8000/1".to_owned()))
394        );
395        assert!(codecs.carries(Codec::L16));
396    }
397
398    #[test]
399    fn an_empty_or_duplicate_selection_is_refused() {
400        assert_eq!(Codecs::ordered(&[]), Err(CodecSelectionError::Empty));
401        assert_eq!(
402            Codecs::ordered(&[CodecPreference::Pcmu, CodecPreference::Pcmu]),
403            Err(CodecSelectionError::Duplicate("pcmu"))
404        );
405    }
406
407    #[cfg(not(feature = "opus"))]
408    #[test]
409    fn opus_is_a_known_but_unavailable_value_without_the_feature() {
410        assert_eq!(
411            Codecs::ordered(&[CodecPreference::Opus]),
412            Err(CodecSelectionError::OpusUnavailable)
413        );
414    }
415
416    #[cfg(feature = "opus")]
417    #[test]
418    fn opus_can_be_placed_anywhere_in_the_order() {
419        let codecs = Codecs::ordered(&[
420            CodecPreference::Pcma,
421            CodecPreference::Opus,
422            CodecPreference::Pcmu,
423        ])
424        .unwrap();
425        let capabilities = codecs.capabilities("192.0.2.9".parse().unwrap(), 40_000);
426        assert_eq!(capabilities.audio_formats, ["8", "111", "0", "101"]);
427        assert!(codecs.carries(Codec::Opus));
428    }
429
430    #[test]
431    fn keying_default_and_explicit_values_are_distinct() {
432        assert_eq!(Keying::default(), Keying::Auto);
433        assert_ne!(Keying::Plain, Keying::Sdes);
434        assert_ne!(Keying::Auto, Keying::Sdes);
435        assert_ne!(Keying::DtlsSrtp, Keying::Plain);
436    }
437}