Skip to main content

wavekat_sip/
sdp.rs

1//! SDP offer/answer for telephony audio — Opus (RFC 7587) preferred,
2//! G.711 fallback — plus RFC 4733 `telephone-event` (DTMF) negotiation.
3//!
4//! The crate stays codec-agnostic: nothing here encodes or decodes
5//! audio. This module only *negotiates* — which codec, at which payload
6//! type, with which DTMF clock — and reports the result as
7//! [`AudioCodec`] / [`DtmfSpec`] for the consumer to act on.
8//!
9//! ## Offer, answer, re-offer
10//!
11//! - **Initial offer** ([`build_sdp_offer`]): the full menu, Opus first
12//!   (`opus/48000/2` with `useinbandfec=1`), then PCMU/PCMA, plus
13//!   `telephone-event` at both the 8 kHz and 48 kHz clocks so whichever
14//!   codec the peer picks it finds a matching-clock DTMF entry.
15//! - **Answer**: a real RFC 3264 intersection — echo back *only* the
16//!   codec we selected from the offer (via [`select_codec`] /
17//!   [`select_dtmf`]), at the offer's own payload-type numbers, built
18//!   with [`CodecMenu::Pinned`].
19//! - **Mid-call re-offers** (hold/resume, session refresh): also
20//!   [`CodecMenu::Pinned`] to the already-negotiated codec. Re-offering
21//!   the full menu would let the peer switch codecs mid-call, which the
22//!   consumer's audio path (encoder state, sample rates, recording) is
23//!   not prepared for; pinning makes that structurally impossible.
24//!
25//! ## The Opus wire constants
26//!
27//! The rtpmap for Opus is always `opus/48000/2` (RFC 7587 §4.1) — the
28//! 48 kHz clock and the 2 channels are wire-format constants, not a
29//! description of the stream (the actual audio is typically mono
30//! wideband). Opus also has no static payload type: it rides the
31//! dynamic range, [`OPUS_DEFAULT_PT`] (111) being only what *we* offer.
32//! The negotiated number comes out of [`parse_sdp`] per call.
33
34use std::net::IpAddr;
35
36/// The de-facto dynamic RTP payload type for `telephone-event/8000`.
37///
38/// Any value in 96-127 is legal under RFC 3551, but ~every softphone
39/// and PBX in the field picks 101 — matching that keeps inter-op
40/// boring and predictable.
41pub const DTMF_DEFAULT_PT: u8 = 101;
42
43/// The dynamic payload type we offer for `telephone-event/48000` —
44/// DTMF at the Opus RTP clock. RFC 4733 §2.1 wants the event clock to
45/// match the audio stream's, so peers that select Opus commonly expect
46/// (and some only accept) telephone-event at 48 kHz. Offered alongside
47/// the classic 8 kHz entry; the answerer picks one.
48pub const DTMF_WIDEBAND_DEFAULT_PT: u8 = 110;
49
50/// The dynamic payload type we offer for Opus. There is no static
51/// assignment — 111 is the field's de-facto default (same spirit as 101
52/// for telephone-event). The peer may counter with any number; always
53/// use the negotiated value from [`parse_sdp`], never this constant,
54/// when talking to the wire.
55pub const OPUS_DEFAULT_PT: u8 = 111;
56
57/// RTP timestamp clock for Opus — pinned to 48 kHz by RFC 7587 §4.1
58/// regardless of the actual encode rate. (G.711's clock is 8 kHz.)
59pub const OPUS_RTP_CLOCK_RATE: u32 = 48_000;
60
61const G711_RTP_CLOCK_RATE: u32 = 8_000;
62
63/// Media direction (RFC 4566 `a=` attribute), used to put a call on hold
64/// and take it off again per RFC 3264 §8.4.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MediaDirection {
67    /// Send and receive media — the normal, active state (`a=sendrecv`).
68    SendRecv,
69    /// Send only, do not receive — places the peer on hold (`a=sendonly`).
70    SendOnly,
71    /// Neither send nor receive — a full two-way hold (`a=inactive`).
72    Inactive,
73}
74
75impl MediaDirection {
76    /// The `a=` attribute line value (no `a=` prefix, no CRLF).
77    pub fn attribute(&self) -> &'static str {
78        match self {
79            MediaDirection::SendRecv => "sendrecv",
80            MediaDirection::SendOnly => "sendonly",
81            MediaDirection::Inactive => "inactive",
82        }
83    }
84}
85
86/// An audio codec this stack can negotiate, with its wire identity.
87///
88/// This is negotiation metadata, not a codec implementation — encode
89/// and decode stay with the consumer (`wavekat-core` provides G.711 and
90/// Opus codecs).
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum AudioCodec {
93    /// G.711 μ-law — static RTP payload type 0.
94    Pcmu,
95    /// G.711 A-law — static RTP payload type 8.
96    Pcma,
97    /// Opus at a **dynamic** payload type — whatever this side of the
98    /// negotiation chose. The number alone is not self-describing the
99    /// way 0/8 are; it only means Opus in the context of the SDP body
100    /// that mapped it.
101    Opus {
102        /// The negotiated dynamic payload type (96-127 in practice).
103        payload_type: u8,
104    },
105}
106
107impl AudioCodec {
108    /// The RTP payload-type number this codec rides on.
109    pub fn payload_type(&self) -> u8 {
110        match self {
111            AudioCodec::Pcmu => 0,
112            AudioCodec::Pcma => 8,
113            AudioCodec::Opus { payload_type } => *payload_type,
114        }
115    }
116
117    /// The RTP timestamp clock rate: 8 kHz for G.711, always 48 kHz for
118    /// Opus (a wire constant — see the module docs).
119    pub fn rtp_clock_rate(&self) -> u32 {
120        match self {
121            AudioCodec::Pcmu | AudioCodec::Pcma => G711_RTP_CLOCK_RATE,
122            AudioCodec::Opus { .. } => OPUS_RTP_CLOCK_RATE,
123        }
124    }
125
126    /// The `a=rtpmap:` encoding string (`<name>/<clock>[/<channels>]`).
127    fn rtpmap_encoding(&self) -> &'static str {
128        match self {
129            AudioCodec::Pcmu => "PCMU/8000",
130            AudioCodec::Pcma => "PCMA/8000",
131            AudioCodec::Opus { .. } => "opus/48000/2",
132        }
133    }
134}
135
136/// One negotiated `telephone-event` entry: its payload type and clock.
137///
138/// The clock matters beyond the SDP line — RFC 4733 duration fields and
139/// burst pacing count in ticks of this clock (160 per 20 ms at 8 kHz,
140/// 960 at 48 kHz), so it must be threaded into
141/// [`crate::rtp::dtmf::DtmfBurstConfig`].
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub struct DtmfSpec {
144    /// The negotiated dynamic payload type.
145    pub payload_type: u8,
146    /// The event clock rate from the rtpmap (8000 classic, 48000
147    /// alongside Opus).
148    pub clock_rate: u32,
149}
150
151/// Which audio codecs an SDP body advertises.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum CodecMenu {
154    /// The full initial-offer menu: Opus (preferred, at
155    /// [`OPUS_DEFAULT_PT`]) then PCMU/PCMA, with `telephone-event` at
156    /// both clocks. Use for initial offers only.
157    Full,
158    /// Exactly one codec (plus optionally one `telephone-event` entry)
159    /// — the shape of every answer and every post-negotiation re-offer.
160    /// Pinning re-offers to the negotiated codec is what keeps a
161    /// hold/resume or session-refresh re-INVITE from renegotiating the
162    /// codec out from under a live audio path.
163    Pinned {
164        /// The negotiated audio codec, echoed at its negotiated payload
165        /// type.
166        codec: AudioCodec,
167        /// The negotiated `telephone-event` entry, if any.
168        dtmf: Option<DtmfSpec>,
169    },
170}
171
172/// Build the initial SDP offer: the full codec menu ([`CodecMenu::Full`]),
173/// `a=sendrecv`, `o=` version 0.
174///
175/// Answers and re-offers must **not** use this — see [`CodecMenu`] and
176/// [`build_sdp_with`].
177pub fn build_sdp_offer(local_ip: IpAddr, rtp_port: u16) -> Vec<u8> {
178    build_sdp_with(
179        local_ip,
180        rtp_port,
181        CodecMenu::Full,
182        MediaDirection::SendRecv,
183        0,
184    )
185}
186
187/// Build an SDP body with an explicit codec `menu`, media `direction`,
188/// and `o=` session `version`.
189///
190/// RFC 3264 §5 requires every re-offer to keep the same session id but
191/// **increment** the `o=` version; hold/resume re-INVITEs feed the bumped
192/// version here and flip `direction` between [`MediaDirection::SendRecv`]
193/// and [`MediaDirection::SendOnly`]/[`MediaDirection::Inactive`] — with
194/// the menu pinned to the negotiated codec ([`CodecMenu::Pinned`]).
195pub fn build_sdp_with(
196    local_ip: IpAddr,
197    rtp_port: u16,
198    menu: CodecMenu,
199    direction: MediaDirection,
200    version: u32,
201) -> Vec<u8> {
202    // Collect the m= line payload types and the a= lines per menu.
203    let mut pts: Vec<String> = Vec::new();
204    let mut attrs: Vec<String> = Vec::new();
205
206    match menu {
207        CodecMenu::Full => {
208            pts.extend([
209                OPUS_DEFAULT_PT.to_string(),
210                "0".into(),
211                "8".into(),
212                DTMF_DEFAULT_PT.to_string(),
213                DTMF_WIDEBAND_DEFAULT_PT.to_string(),
214            ]);
215            attrs.push(format!("a=rtpmap:{OPUS_DEFAULT_PT} opus/48000/2"));
216            attrs.push(format!("a=fmtp:{OPUS_DEFAULT_PT} useinbandfec=1"));
217            attrs.push("a=rtpmap:0 PCMU/8000".into());
218            attrs.push("a=rtpmap:8 PCMA/8000".into());
219            attrs.push(format!("a=rtpmap:{DTMF_DEFAULT_PT} telephone-event/8000"));
220            attrs.push(format!("a=fmtp:{DTMF_DEFAULT_PT} 0-15"));
221            attrs.push(format!(
222                "a=rtpmap:{DTMF_WIDEBAND_DEFAULT_PT} telephone-event/{OPUS_RTP_CLOCK_RATE}"
223            ));
224            attrs.push(format!("a=fmtp:{DTMF_WIDEBAND_DEFAULT_PT} 0-15"));
225        }
226        CodecMenu::Pinned { codec, dtmf } => {
227            let pt = codec.payload_type();
228            pts.push(pt.to_string());
229            attrs.push(format!("a=rtpmap:{pt} {}", codec.rtpmap_encoding()));
230            if matches!(codec, AudioCodec::Opus { .. }) {
231                attrs.push(format!("a=fmtp:{pt} useinbandfec=1"));
232            }
233            if let Some(DtmfSpec {
234                payload_type,
235                clock_rate,
236            }) = dtmf
237            {
238                pts.push(payload_type.to_string());
239                attrs.push(format!(
240                    "a=rtpmap:{payload_type} telephone-event/{clock_rate}"
241                ));
242                attrs.push(format!("a=fmtp:{payload_type} 0-15"));
243            }
244        }
245    }
246
247    let pt_list = pts.join(" ");
248    let attr_lines = attrs.join("\r\n");
249    let dir = direction.attribute();
250    format!(
251        "v=0\r\n\
252         o=wavekat 0 {version} IN IP4 {local_ip}\r\n\
253         s=wavekat-sip\r\n\
254         c=IN IP4 {local_ip}\r\n\
255         t=0 0\r\n\
256         m=audio {rtp_port} RTP/AVP {pt_list}\r\n\
257         {attr_lines}\r\n\
258         a={dir}\r\n"
259    )
260    .into_bytes()
261}
262
263/// Remote media info extracted from an SDP body.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct RemoteMedia {
266    /// Remote IP from the `c=` line.
267    pub addr: IpAddr,
268    /// Remote RTP port from the `m=audio` line.
269    pub port: u16,
270    /// First (preferred) RTP payload type from the `m=audio` line, raw.
271    /// Kept for logging/diagnostics; consumers should act on [`codec`]
272    /// (`Self::codec`), which resolves dynamic payload types.
273    pub payload_type: u8,
274    /// The first payload type on the `m=audio` line that resolves to a
275    /// codec this stack knows (static 0/8, or a dynamic PT whose rtpmap
276    /// names `opus/48000` or PCMU/PCMA). `None` when the body offers
277    /// nothing we understand.
278    ///
279    /// When parsed from an **answer** this *is* the negotiated codec.
280    /// When parsed from an **offer** it is only the peer's first
281    /// preference — the selection is ours to make ([`select_codec`]).
282    /// [`crate::IncomingCall::accept`] rewrites this field to the codec
283    /// it actually selected, so on an established [`crate::Call`] it is
284    /// always the negotiated codec.
285    pub codec: Option<AudioCodec>,
286    /// The payload type Opus rides on, if Opus appears **anywhere** in
287    /// the menu (not just first). This is what lets the answer side
288    /// prefer Opus over a peer that offers `0 8 111`.
289    pub opus_payload_type: Option<u8>,
290    /// Every `telephone-event` entry on the `m=audio` line, in offer
291    /// order, with its clock. Use [`Self::dtmf`] for "the one that goes
292    /// with the negotiated codec".
293    pub dtmf_events: Vec<DtmfSpec>,
294}
295
296impl RemoteMedia {
297    /// The `telephone-event` entry to use with the negotiated codec:
298    /// the one whose clock matches the codec's RTP clock, else the
299    /// first offered. `None` if the remote advertised no
300    /// telephone-event at all — fall back to SIP INFO for DTMF then.
301    pub fn dtmf(&self) -> Option<DtmfSpec> {
302        match self.codec {
303            Some(codec) => select_dtmf(self, codec),
304            None => self.dtmf_events.first().copied(),
305        }
306    }
307}
308
309/// Pick the audio codec for an answer, per the WaveKat policy: **prefer
310/// Opus wherever it appears in the offer, else the peer's first G.711**.
311/// Returns `None` when the offer contains nothing this stack knows —
312/// the SIP-correct response then is `488 Not Acceptable Here`.
313pub fn select_codec(offer: &RemoteMedia) -> Option<AudioCodec> {
314    offer
315        .opus_payload_type
316        .map(|payload_type| AudioCodec::Opus { payload_type })
317        .or(offer.codec)
318}
319
320/// Pick the `telephone-event` entry to answer with, given the selected
321/// audio codec: matching clock first (RFC 4733 §2.1 wants the event
322/// clock to follow the audio stream's), else the first offered entry.
323pub fn select_dtmf(offer: &RemoteMedia, codec: AudioCodec) -> Option<DtmfSpec> {
324    let clock = codec.rtp_clock_rate();
325    offer
326        .dtmf_events
327        .iter()
328        .copied()
329        .find(|d| d.clock_rate == clock)
330        .or_else(|| offer.dtmf_events.first().copied())
331}
332
333/// Parse the connection address, audio port, codec menu, and
334/// `telephone-event` entries from an SDP body.
335pub fn parse_sdp(sdp_bytes: &[u8]) -> Result<RemoteMedia, String> {
336    let sdp = std::str::from_utf8(sdp_bytes).map_err(|e| format!("SDP not UTF-8: {e}"))?;
337
338    let mut addr: Option<IpAddr> = None;
339    let mut port: Option<u16> = None;
340    let mut audio_pts: Vec<u8> = Vec::new();
341    // Every parsed a=rtpmap: line — (pt, lowercased encoding name, clock).
342    let mut rtpmaps: Vec<(u8, String, u32)> = Vec::new();
343
344    for line in sdp.lines() {
345        let line = line.trim();
346
347        // c=IN IP4 10.0.0.1
348        if line.starts_with("c=IN IP4 ") || line.starts_with("c=IN IP6 ") {
349            if let Some(ip_str) = line.split_whitespace().nth(2) {
350                addr = ip_str.parse().ok();
351            }
352        }
353
354        // m=audio 20000 RTP/AVP 111 0 8 101
355        // The first payload type after RTP/AVP is the preferred codec;
356        // the rest may include more codecs and telephone-event.
357        if line.starts_with("m=audio ") {
358            let parts: Vec<&str> = line.split_whitespace().collect();
359            if parts.len() >= 2 {
360                port = parts[1].parse().ok();
361            }
362            // Skip "m=audio", port, "RTP/AVP" — payload types start at index 3.
363            for pt_str in parts.iter().skip(3) {
364                if let Ok(pt) = pt_str.parse::<u8>() {
365                    audio_pts.push(pt);
366                }
367            }
368        }
369
370        // a=rtpmap:111 opus/48000/2
371        // a=rtpmap:101 telephone-event/8000
372        if let Some(rest) = line.strip_prefix("a=rtpmap:") {
373            // <pt> <name>/<rate>[/params]
374            let mut sp = rest.splitn(2, char::is_whitespace);
375            let pt_str = sp.next().unwrap_or("");
376            let mapping = sp.next().unwrap_or("");
377            if let Ok(pt) = pt_str.parse::<u8>() {
378                let name_lc = mapping.split('/').next().unwrap_or("").to_ascii_lowercase();
379                if let Ok(rate) = mapping.split('/').nth(1).unwrap_or("").parse::<u32>() {
380                    rtpmaps.push((pt, name_lc, rate));
381                }
382            }
383        }
384    }
385
386    let rtpmap_for = |pt: u8| rtpmaps.iter().find(|(p, _, _)| *p == pt);
387
388    // Resolve one m=-line payload type to a codec. An explicit rtpmap
389    // wins over the static assignment (RFC 8866 allows remapping);
390    // without one, only the static 0/8 numbers are self-describing.
391    let resolve = |pt: u8| -> Option<AudioCodec> {
392        match rtpmap_for(pt) {
393            Some((_, name, rate)) => match (name.as_str(), *rate) {
394                ("opus", OPUS_RTP_CLOCK_RATE) => Some(AudioCodec::Opus { payload_type: pt }),
395                ("pcmu", G711_RTP_CLOCK_RATE) => Some(AudioCodec::Pcmu),
396                ("pcma", G711_RTP_CLOCK_RATE) => Some(AudioCodec::Pcma),
397                _ => None,
398            },
399            None => match pt {
400                0 => Some(AudioCodec::Pcmu),
401                8 => Some(AudioCodec::Pcma),
402                _ => None,
403            },
404        }
405    };
406
407    let codec = audio_pts.iter().copied().find_map(resolve);
408    let opus_payload_type = audio_pts
409        .iter()
410        .copied()
411        .find(|&pt| matches!(resolve(pt), Some(AudioCodec::Opus { .. })));
412
413    // telephone-event must both be named in an rtpmap *and* listed on
414    // the m=audio line for the negotiation to be valid — at any clock
415    // rate (8000 classic, 48000 alongside Opus, 16000 exists in the
416    // wild too). The clock is recorded because duration math depends
417    // on it.
418    let dtmf_events: Vec<DtmfSpec> = audio_pts
419        .iter()
420        .filter_map(|&pt| match rtpmap_for(pt) {
421            Some((_, name, rate)) if name == "telephone-event" => Some(DtmfSpec {
422                payload_type: pt,
423                clock_rate: *rate,
424            }),
425            _ => None,
426        })
427        .collect();
428
429    let payload_type = audio_pts.first().copied();
430
431    match (addr, port, payload_type) {
432        (Some(addr), Some(port), Some(pt)) => Ok(RemoteMedia {
433            addr,
434            port,
435            payload_type: pt,
436            codec,
437            opus_payload_type,
438            dtmf_events,
439        }),
440        (None, _, _) => Err("No connection address (c=) in SDP".to_string()),
441        (_, None, _) => Err("No audio media line (m=audio) in SDP".to_string()),
442        (_, _, None) => Err("No payload type in m=audio line".to_string()),
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use std::net::Ipv4Addr;
450
451    fn ip() -> IpAddr {
452        IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))
453    }
454
455    #[test]
456    fn offer_contains_required_fields_opus_first() {
457        let sdp = build_sdp_offer(ip(), 5004);
458        let text = String::from_utf8(sdp).unwrap();
459
460        assert!(text.contains("v=0\r\n"));
461        assert!(text.contains("c=IN IP4 10.0.0.1\r\n"));
462        // Opus leads the menu; G.711 follows for fallback; both DTMF
463        // clocks are on the table.
464        assert!(text.contains("m=audio 5004 RTP/AVP 111 0 8 101 110\r\n"));
465        assert!(text.contains("a=rtpmap:111 opus/48000/2\r\n"));
466        assert!(text.contains("a=fmtp:111 useinbandfec=1\r\n"));
467        assert!(text.contains("a=rtpmap:0 PCMU/8000\r\n"));
468        assert!(text.contains("a=rtpmap:8 PCMA/8000\r\n"));
469        assert!(text.contains("a=rtpmap:101 telephone-event/8000\r\n"));
470        assert!(text.contains("a=fmtp:101 0-15\r\n"));
471        assert!(text.contains("a=rtpmap:110 telephone-event/48000\r\n"));
472        assert!(text.contains("a=fmtp:110 0-15\r\n"));
473        assert!(text.contains("a=sendrecv\r\n"));
474        assert!(text.contains("o=wavekat 0 0 IN IP4 10.0.0.1\r\n"));
475    }
476
477    #[test]
478    fn build_sdp_with_sets_direction_and_version() {
479        let menu = CodecMenu::Pinned {
480            codec: AudioCodec::Pcmu,
481            dtmf: Some(DtmfSpec {
482                payload_type: 101,
483                clock_rate: 8000,
484            }),
485        };
486
487        let hold = String::from_utf8(build_sdp_with(
488            ip(),
489            5004,
490            menu,
491            MediaDirection::SendOnly,
492            1,
493        ))
494        .unwrap();
495        assert!(hold.contains("o=wavekat 0 1 IN IP4 10.0.0.1\r\n"));
496        assert!(hold.contains("a=sendonly\r\n"));
497        assert!(!hold.contains("a=sendrecv\r\n"));
498
499        let resume = String::from_utf8(build_sdp_with(
500            ip(),
501            5004,
502            menu,
503            MediaDirection::SendRecv,
504            2,
505        ))
506        .unwrap();
507        assert!(resume.contains("o=wavekat 0 2 IN IP4 10.0.0.1\r\n"));
508        assert!(resume.contains("a=sendrecv\r\n"));
509
510        let inactive = String::from_utf8(build_sdp_with(
511            ip(),
512            5004,
513            menu,
514            MediaDirection::Inactive,
515            3,
516        ))
517        .unwrap();
518        assert!(inactive.contains("a=inactive\r\n"));
519    }
520
521    #[test]
522    fn pinned_g711_reoffer_contains_only_the_negotiated_codec() {
523        // A hold re-INVITE after a PCMA call must not re-open the Opus
524        // (or PCMU) negotiation.
525        let menu = CodecMenu::Pinned {
526            codec: AudioCodec::Pcma,
527            dtmf: Some(DtmfSpec {
528                payload_type: 101,
529                clock_rate: 8000,
530            }),
531        };
532        let text = String::from_utf8(build_sdp_with(
533            ip(),
534            5004,
535            menu,
536            MediaDirection::SendOnly,
537            1,
538        ))
539        .unwrap();
540        assert!(text.contains("m=audio 5004 RTP/AVP 8 101\r\n"));
541        assert!(text.contains("a=rtpmap:8 PCMA/8000\r\n"));
542        assert!(!text.contains("opus"));
543        assert!(!text.contains("PCMU"));
544    }
545
546    #[test]
547    fn pinned_opus_reoffer_keeps_the_negotiated_pt_and_dtmf_clock() {
548        // The peer negotiated Opus at *its* PT (96, not our default 111)
549        // and telephone-event at the 48 kHz clock. Re-offers must echo
550        // those exact numbers.
551        let menu = CodecMenu::Pinned {
552            codec: AudioCodec::Opus { payload_type: 96 },
553            dtmf: Some(DtmfSpec {
554                payload_type: 97,
555                clock_rate: 48000,
556            }),
557        };
558        let text = String::from_utf8(build_sdp_with(
559            ip(),
560            5004,
561            menu,
562            MediaDirection::SendOnly,
563            1,
564        ))
565        .unwrap();
566        assert!(text.contains("m=audio 5004 RTP/AVP 96 97\r\n"));
567        assert!(text.contains("a=rtpmap:96 opus/48000/2\r\n"));
568        assert!(text.contains("a=fmtp:96 useinbandfec=1\r\n"));
569        assert!(text.contains("a=rtpmap:97 telephone-event/48000\r\n"));
570        assert!(!text.contains("111"));
571        assert!(!text.contains("PCMU"));
572        assert!(!text.contains("PCMA"));
573    }
574
575    #[test]
576    fn pinned_without_dtmf_omits_telephone_event() {
577        let menu = CodecMenu::Pinned {
578            codec: AudioCodec::Pcmu,
579            dtmf: None,
580        };
581        let text = String::from_utf8(build_sdp_with(
582            ip(),
583            5004,
584            menu,
585            MediaDirection::SendRecv,
586            0,
587        ))
588        .unwrap();
589        assert!(text.contains("m=audio 5004 RTP/AVP 0\r\n"));
590        assert!(!text.contains("telephone-event"));
591    }
592
593    #[test]
594    fn parse_sdp_extracts_addr_port_and_codec() {
595        let sdp = b"v=0\r\nc=IN IP4 192.168.1.100\r\nm=audio 20000 RTP/AVP 0\r\n";
596        let media = parse_sdp(sdp).unwrap();
597        assert_eq!(media.addr, Ipv4Addr::new(192, 168, 1, 100));
598        assert_eq!(media.port, 20000);
599        assert_eq!(media.payload_type, 0);
600        assert_eq!(media.codec, Some(AudioCodec::Pcmu));
601        assert_eq!(media.opus_payload_type, None);
602        // No rtpmap for telephone-event → no DTMF advertised.
603        assert_eq!(media.dtmf(), None);
604    }
605
606    #[test]
607    fn parse_sdp_extracts_first_codec_when_multiple() {
608        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 8 0 101\r\n\
609                    a=rtpmap:101 telephone-event/8000\r\n";
610        let media = parse_sdp(sdp).unwrap();
611        // First PT in the m= line is still the preferred audio codec — adding
612        // telephone-event must not change which codec the consumer thinks the
613        // remote wants for voice.
614        assert_eq!(media.payload_type, 8); // PCMA is first/preferred
615        assert_eq!(media.codec, Some(AudioCodec::Pcma));
616        assert_eq!(
617            media.dtmf(),
618            Some(DtmfSpec {
619                payload_type: 101,
620                clock_rate: 8000
621            })
622        );
623    }
624
625    #[test]
626    fn parse_sdp_resolves_dynamic_opus_pt() {
627        // Opus has no static PT — the peer picked 96, and only the
628        // rtpmap says what 96 means.
629        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 96 0 101\r\n\
630                    a=rtpmap:96 opus/48000/2\r\n\
631                    a=rtpmap:0 PCMU/8000\r\n\
632                    a=rtpmap:101 telephone-event/8000\r\n";
633        let media = parse_sdp(sdp).unwrap();
634        assert_eq!(media.payload_type, 96);
635        assert_eq!(media.codec, Some(AudioCodec::Opus { payload_type: 96 }));
636        assert_eq!(media.opus_payload_type, Some(96));
637    }
638
639    #[test]
640    fn parse_sdp_finds_opus_even_when_not_first() {
641        // Peer prefers PCMU but also offers Opus. `codec` reports their
642        // preference; `opus_payload_type` is what lets our answer side
643        // still pick Opus.
644        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 8 111\r\n\
645                    a=rtpmap:111 opus/48000/2\r\n";
646        let media = parse_sdp(sdp).unwrap();
647        assert_eq!(media.codec, Some(AudioCodec::Pcmu));
648        assert_eq!(media.opus_payload_type, Some(111));
649    }
650
651    #[test]
652    fn parse_sdp_skips_unknown_dynamic_pt_to_next_known_codec() {
653        // First PT is something we don't speak (G.729 at 18); the
654        // resolved codec must be the next understood one, while the raw
655        // first PT stays observable.
656        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 18 8 0\r\n\
657                    a=rtpmap:18 G729/8000\r\n";
658        let media = parse_sdp(sdp).unwrap();
659        assert_eq!(media.payload_type, 18);
660        assert_eq!(media.codec, Some(AudioCodec::Pcma));
661    }
662
663    #[test]
664    fn parse_sdp_no_known_codec_yields_none() {
665        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 18\r\n\
666                    a=rtpmap:18 G729/8000\r\n";
667        let media = parse_sdp(sdp).unwrap();
668        assert_eq!(media.codec, None);
669        assert_eq!(select_codec(&media), None);
670    }
671
672    #[test]
673    fn round_trip_build_offer_then_parse() {
674        let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5));
675        let sdp = build_sdp_offer(ip, 8000);
676        let media = parse_sdp(&sdp).unwrap();
677        assert_eq!(media.addr, ip);
678        assert_eq!(media.port, 8000);
679        // Opus leads our own offer.
680        assert_eq!(media.payload_type, OPUS_DEFAULT_PT);
681        assert_eq!(
682            media.codec,
683            Some(AudioCodec::Opus {
684                payload_type: OPUS_DEFAULT_PT
685            })
686        );
687        // Both DTMF clocks parse back, and the Opus-matched pick is the
688        // 48 kHz one.
689        assert_eq!(media.dtmf_events.len(), 2);
690        assert_eq!(
691            media.dtmf(),
692            Some(DtmfSpec {
693                payload_type: DTMF_WIDEBAND_DEFAULT_PT,
694                clock_rate: 48000
695            })
696        );
697    }
698
699    #[test]
700    fn round_trip_pinned_answer_then_parse() {
701        // Build the answer shape accept() sends and confirm the caller
702        // side parses the negotiated codec back out of it.
703        let menu = CodecMenu::Pinned {
704            codec: AudioCodec::Opus { payload_type: 111 },
705            dtmf: Some(DtmfSpec {
706                payload_type: 110,
707                clock_rate: 48000,
708            }),
709        };
710        let sdp = build_sdp_with(ip(), 8000, menu, MediaDirection::SendRecv, 0);
711        let media = parse_sdp(&sdp).unwrap();
712        assert_eq!(media.codec, Some(AudioCodec::Opus { payload_type: 111 }));
713        assert_eq!(
714            media.dtmf(),
715            Some(DtmfSpec {
716                payload_type: 110,
717                clock_rate: 48000
718            })
719        );
720    }
721
722    #[test]
723    fn select_codec_prefers_opus_anywhere_in_the_offer() {
724        // Peer's m= line prefers PCMU but lists Opus later — our policy
725        // still picks Opus, at the peer's PT.
726        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 8 102\r\n\
727                    a=rtpmap:102 opus/48000/2\r\n";
728        let media = parse_sdp(sdp).unwrap();
729        assert_eq!(
730            select_codec(&media),
731            Some(AudioCodec::Opus { payload_type: 102 })
732        );
733    }
734
735    #[test]
736    fn select_codec_falls_back_to_g711_without_opus() {
737        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 8 0\r\n";
738        let media = parse_sdp(sdp).unwrap();
739        // No Opus in the offer → the peer's first G.711, no Opus in the
740        // answer.
741        assert_eq!(select_codec(&media), Some(AudioCodec::Pcma));
742    }
743
744    #[test]
745    fn select_dtmf_matches_the_codec_clock() {
746        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 111 0 101 110\r\n\
747                    a=rtpmap:111 opus/48000/2\r\n\
748                    a=rtpmap:101 telephone-event/8000\r\n\
749                    a=rtpmap:110 telephone-event/48000\r\n";
750        let media = parse_sdp(sdp).unwrap();
751        // Opus selected → the 48 kHz event entry, even though the 8 kHz
752        // one is listed first.
753        assert_eq!(
754            select_dtmf(&media, AudioCodec::Opus { payload_type: 111 }),
755            Some(DtmfSpec {
756                payload_type: 110,
757                clock_rate: 48000
758            })
759        );
760        // G.711 selected → the 8 kHz entry.
761        assert_eq!(
762            select_dtmf(&media, AudioCodec::Pcmu),
763            Some(DtmfSpec {
764                payload_type: 101,
765                clock_rate: 8000
766            })
767        );
768    }
769
770    #[test]
771    fn select_dtmf_falls_back_to_any_clock() {
772        // Peer paired Opus with only an 8 kHz telephone-event (common
773        // with older stacks). Better to use it at its own clock than to
774        // degrade to SIP INFO.
775        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 111 101\r\n\
776                    a=rtpmap:111 opus/48000/2\r\n\
777                    a=rtpmap:101 telephone-event/8000\r\n";
778        let media = parse_sdp(sdp).unwrap();
779        assert_eq!(
780            select_dtmf(&media, AudioCodec::Opus { payload_type: 111 }),
781            Some(DtmfSpec {
782                payload_type: 101,
783                clock_rate: 8000
784            })
785        );
786    }
787
788    #[test]
789    fn parse_sdp_accepts_telephone_event_at_48k() {
790        // The regression this whole DTMF rework exists for: a peer that
791        // pairs Opus with telephone-event/48000 must not be reported as
792        // "no DTMF" (which silently degrades to SIP INFO).
793        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 111 110\r\n\
794                    a=rtpmap:111 opus/48000/2\r\n\
795                    a=rtpmap:110 telephone-event/48000\r\n";
796        let media = parse_sdp(sdp).unwrap();
797        assert_eq!(
798            media.dtmf(),
799            Some(DtmfSpec {
800                payload_type: 110,
801                clock_rate: 48000
802            })
803        );
804    }
805
806    #[test]
807    fn parse_sdp_records_telephone_event_at_any_clock() {
808        // RFC 4733 also defines other clocks (16000 for wideband
809        // codecs). We record the advertised clock instead of rejecting
810        // it — using the peer's entry at its own clock beats degrading
811        // to SIP INFO.
812        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
813                    a=rtpmap:0 PCMU/8000\r\n\
814                    a=rtpmap:101 telephone-event/16000\r\n";
815        let media = parse_sdp(sdp).unwrap();
816        assert_eq!(
817            media.dtmf(),
818            Some(DtmfSpec {
819                payload_type: 101,
820                clock_rate: 16000
821            })
822        );
823    }
824
825    #[test]
826    fn parse_sdp_picks_dtmf_pt_from_rtpmap_not_constant() {
827        // Some remotes negotiate telephone-event on a different dynamic PT
828        // (96-127). The parser must follow the rtpmap, not assume 101.
829        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 96\r\n\
830                    a=rtpmap:0 PCMU/8000\r\n\
831                    a=rtpmap:96 telephone-event/8000\r\n";
832        let media = parse_sdp(sdp).unwrap();
833        assert_eq!(
834            media.dtmf(),
835            Some(DtmfSpec {
836                payload_type: 96,
837                clock_rate: 8000
838            })
839        );
840    }
841
842    #[test]
843    fn parse_sdp_rejects_telephone_event_not_listed_on_m_line() {
844        // rtpmap names PT 101 but the m=audio line doesn't include it →
845        // not actually negotiated, must be ignored.
846        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0\r\n\
847                    a=rtpmap:0 PCMU/8000\r\n\
848                    a=rtpmap:101 telephone-event/8000\r\n";
849        let media = parse_sdp(sdp).unwrap();
850        assert_eq!(media.dtmf(), None);
851    }
852
853    #[test]
854    fn parse_sdp_handles_mixed_case_telephone_event() {
855        // Some stacks emit "Telephone-Event" or "TELEPHONE-EVENT". Case
856        // matching at the rtpmap level shouldn't reject those.
857        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
858                    a=rtpmap:0 PCMU/8000\r\n\
859                    a=rtpmap:101 Telephone-Event/8000\r\n";
860        let media = parse_sdp(sdp).unwrap();
861        assert_eq!(
862            media.dtmf(),
863            Some(DtmfSpec {
864                payload_type: 101,
865                clock_rate: 8000
866            })
867        );
868    }
869
870    #[test]
871    fn parse_sdp_handles_telephone_event_without_fmtp() {
872        // `a=fmtp:101 0-15` is optional; many stacks omit it. Absence must
873        // not gate negotiation.
874        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
875                    a=rtpmap:0 PCMU/8000\r\n\
876                    a=rtpmap:101 telephone-event/8000\r\n";
877        let media = parse_sdp(sdp).unwrap();
878        assert!(media.dtmf().is_some());
879    }
880
881    #[test]
882    fn parse_sdp_g711_only_body_still_parses_as_before() {
883        // Regression guard: the pre-Opus wire shape (what every 0.1.x
884        // peer and most PBXes send) must keep parsing identically.
885        let sdp = b"v=0\r\n\
886                    o=wavekat 0 0 IN IP4 10.0.0.2\r\n\
887                    s=wavekat-sip\r\n\
888                    c=IN IP4 10.0.0.2\r\n\
889                    t=0 0\r\n\
890                    m=audio 5004 RTP/AVP 0 8 101\r\n\
891                    a=rtpmap:0 PCMU/8000\r\n\
892                    a=rtpmap:8 PCMA/8000\r\n\
893                    a=rtpmap:101 telephone-event/8000\r\n\
894                    a=fmtp:101 0-15\r\n\
895                    a=sendrecv\r\n";
896        let media = parse_sdp(sdp).unwrap();
897        assert_eq!(media.payload_type, 0);
898        assert_eq!(media.codec, Some(AudioCodec::Pcmu));
899        assert_eq!(media.opus_payload_type, None);
900        assert_eq!(
901            media.dtmf(),
902            Some(DtmfSpec {
903                payload_type: 101,
904                clock_rate: 8000
905            })
906        );
907    }
908
909    #[test]
910    fn parse_sdp_missing_connection_line() {
911        let sdp = b"v=0\r\nm=audio 20000 RTP/AVP 0\r\n";
912        let err = parse_sdp(sdp).unwrap_err();
913        assert!(err.contains("connection address"));
914    }
915
916    #[test]
917    fn parse_sdp_missing_media_line() {
918        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\n";
919        let err = parse_sdp(sdp).unwrap_err();
920        assert!(err.contains("audio media"));
921    }
922
923    #[test]
924    fn parse_sdp_invalid_utf8() {
925        let sdp = &[0xFF, 0xFE, 0xFD];
926        let err = parse_sdp(sdp).unwrap_err();
927        assert!(err.contains("UTF-8"));
928    }
929
930    #[test]
931    fn audio_codec_wire_identities() {
932        assert_eq!(AudioCodec::Pcmu.payload_type(), 0);
933        assert_eq!(AudioCodec::Pcma.payload_type(), 8);
934        assert_eq!(AudioCodec::Opus { payload_type: 96 }.payload_type(), 96);
935        assert_eq!(AudioCodec::Pcmu.rtp_clock_rate(), 8000);
936        assert_eq!(AudioCodec::Pcma.rtp_clock_rate(), 8000);
937        // RFC 7587 §4.1: the Opus RTP clock is 48 kHz no matter what.
938        assert_eq!(
939            AudioCodec::Opus { payload_type: 96 }.rtp_clock_rate(),
940            48000
941        );
942    }
943}