Skip to main content

wavekat_sip/
sdp.rs

1//! Minimal SDP offer/answer for G.711 telephony audio plus RFC 4733
2//! `telephone-event` (DTMF) negotiation.
3
4use std::net::IpAddr;
5
6/// The de-facto dynamic RTP payload type for `telephone-event/8000`.
7///
8/// Any value in 96-127 is legal under RFC 3551, but ~every softphone
9/// and PBX in the field picks 101 — matching that keeps inter-op
10/// boring and predictable.
11pub const DTMF_DEFAULT_PT: u8 = 101;
12
13/// Build a minimal SDP body for bidirectional G.711 audio with RFC 4733
14/// telephone-event (DTMF) advertised at payload type 101.
15///
16/// Suitable as both the offer (sent in an outbound INVITE) and the answer
17/// (sent in a 200 OK to an inbound INVITE). The G.711 codecs stay
18/// listed first so the remote still selects PCMU/PCMA as the audio
19/// codec; the 101 entry adds DTMF support without changing the
20/// preferred audio choice.
21pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec<u8> {
22    format!(
23        "v=0\r\n\
24         o=wavekat 0 0 IN IP4 {local_ip}\r\n\
25         s=wavekat-sip\r\n\
26         c=IN IP4 {local_ip}\r\n\
27         t=0 0\r\n\
28         m=audio {rtp_port} RTP/AVP 0 8 {DTMF_DEFAULT_PT}\r\n\
29         a=rtpmap:0 PCMU/8000\r\n\
30         a=rtpmap:8 PCMA/8000\r\n\
31         a=rtpmap:{DTMF_DEFAULT_PT} telephone-event/8000\r\n\
32         a=fmtp:{DTMF_DEFAULT_PT} 0-15\r\n\
33         a=sendrecv\r\n"
34    )
35    .into_bytes()
36}
37
38/// Remote media info extracted from an SDP body.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RemoteMedia {
41    /// Remote IP from the `c=` line.
42    pub addr: IpAddr,
43    /// Remote RTP port from the `m=audio` line.
44    pub port: u16,
45    /// First (preferred) RTP payload type from the `m=audio` line.
46    pub payload_type: u8,
47    /// Payload type the remote offered (or accepted) for RFC 4733
48    /// `telephone-event/8000`. `None` if the remote did not advertise
49    /// it — consumers should fall back to SIP INFO for DTMF in that
50    /// case.
51    pub dtmf_payload_type: Option<u8>,
52}
53
54/// Parse the connection address, audio port, preferred codec, and
55/// (optionally) the negotiated DTMF payload type from an SDP body.
56pub fn parse_sdp(sdp_bytes: &[u8]) -> Result<RemoteMedia, String> {
57    let sdp = std::str::from_utf8(sdp_bytes).map_err(|e| format!("SDP not UTF-8: {e}"))?;
58
59    let mut addr: Option<IpAddr> = None;
60    let mut port: Option<u16> = None;
61    let mut audio_pts: Vec<u8> = Vec::new();
62    // PTs whose rtpmap names them as `telephone-event/8000`.
63    let mut dtmf_candidate_pts: Vec<u8> = Vec::new();
64
65    for line in sdp.lines() {
66        let line = line.trim();
67
68        // c=IN IP4 10.0.0.1
69        if line.starts_with("c=IN IP4 ") || line.starts_with("c=IN IP6 ") {
70            if let Some(ip_str) = line.split_whitespace().nth(2) {
71                addr = ip_str.parse().ok();
72            }
73        }
74
75        // m=audio 20000 RTP/AVP 0 8 101
76        // The first payload type after RTP/AVP is the preferred codec;
77        // the rest may include telephone-event.
78        if line.starts_with("m=audio ") {
79            let parts: Vec<&str> = line.split_whitespace().collect();
80            if parts.len() >= 2 {
81                port = parts[1].parse().ok();
82            }
83            // Skip "m=audio", port, "RTP/AVP" — payload types start at index 3.
84            for pt_str in parts.iter().skip(3) {
85                if let Ok(pt) = pt_str.parse::<u8>() {
86                    audio_pts.push(pt);
87                }
88            }
89        }
90
91        // a=rtpmap:101 telephone-event/8000
92        if let Some(rest) = line.strip_prefix("a=rtpmap:") {
93            // <pt> <name>/<rate>[/params]
94            let mut sp = rest.splitn(2, char::is_whitespace);
95            let pt_str = sp.next().unwrap_or("");
96            let mapping = sp.next().unwrap_or("");
97            if let Ok(pt) = pt_str.parse::<u8>() {
98                let name_lc = mapping.split('/').next().unwrap_or("").to_ascii_lowercase();
99                let rate = mapping.split('/').nth(1).unwrap_or("");
100                if name_lc == "telephone-event" && rate == "8000" {
101                    dtmf_candidate_pts.push(pt);
102                }
103            }
104        }
105    }
106
107    // DTMF must both be named in an rtpmap *and* listed on the m=audio line
108    // for the negotiation to be valid. If either is missing, treat DTMF as
109    // not negotiated.
110    let dtmf_payload_type = dtmf_candidate_pts
111        .into_iter()
112        .find(|pt| audio_pts.contains(pt));
113
114    let payload_type = audio_pts.first().copied();
115
116    match (addr, port, payload_type) {
117        (Some(addr), Some(port), Some(pt)) => Ok(RemoteMedia {
118            addr,
119            port,
120            payload_type: pt,
121            dtmf_payload_type,
122        }),
123        (None, _, _) => Err("No connection address (c=) in SDP".to_string()),
124        (_, None, _) => Err("No audio media line (m=audio) in SDP".to_string()),
125        (_, _, None) => Err("No payload type in m=audio line".to_string()),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::net::Ipv4Addr;
133
134    #[test]
135    fn build_sdp_contains_required_fields() {
136        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
137        let sdp = build_sdp(ip, 5004);
138        let text = String::from_utf8(sdp).unwrap();
139
140        assert!(text.contains("v=0\r\n"));
141        assert!(text.contains("c=IN IP4 10.0.0.1\r\n"));
142        assert!(text.contains("m=audio 5004 RTP/AVP 0 8 101\r\n"));
143        assert!(text.contains("a=sendrecv\r\n"));
144        assert!(text.contains("a=rtpmap:0 PCMU/8000\r\n"));
145        assert!(text.contains("a=rtpmap:8 PCMA/8000\r\n"));
146        assert!(text.contains("a=rtpmap:101 telephone-event/8000\r\n"));
147        assert!(text.contains("a=fmtp:101 0-15\r\n"));
148    }
149
150    #[test]
151    fn parse_sdp_extracts_addr_port_and_codec() {
152        let sdp = b"v=0\r\nc=IN IP4 192.168.1.100\r\nm=audio 20000 RTP/AVP 0\r\n";
153        let media = parse_sdp(sdp).unwrap();
154        assert_eq!(media.addr, Ipv4Addr::new(192, 168, 1, 100));
155        assert_eq!(media.port, 20000);
156        assert_eq!(media.payload_type, 0);
157        // No rtpmap for telephone-event → no DTMF advertised.
158        assert_eq!(media.dtmf_payload_type, None);
159    }
160
161    #[test]
162    fn parse_sdp_extracts_first_codec_when_multiple() {
163        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 8 0 101\r\n\
164                    a=rtpmap:101 telephone-event/8000\r\n";
165        let media = parse_sdp(sdp).unwrap();
166        // First PT in the m= line is still the preferred audio codec — adding
167        // telephone-event must not change which codec the consumer thinks the
168        // remote wants for voice.
169        assert_eq!(media.payload_type, 8); // PCMA is first/preferred
170        assert_eq!(media.dtmf_payload_type, Some(101));
171    }
172
173    #[test]
174    fn round_trip_build_then_parse() {
175        let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5));
176        let sdp = build_sdp(ip, 8000);
177        let media = parse_sdp(&sdp).unwrap();
178        assert_eq!(media.addr, ip);
179        assert_eq!(media.port, 8000);
180        assert_eq!(media.payload_type, 0); // PCMU is first in our SDP
181        assert_eq!(media.dtmf_payload_type, Some(DTMF_DEFAULT_PT));
182    }
183
184    #[test]
185    fn parse_sdp_picks_dtmf_pt_from_rtpmap_not_constant() {
186        // Some remotes negotiate telephone-event on a different dynamic PT
187        // (96-127). The parser must follow the rtpmap, not assume 101.
188        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 96\r\n\
189                    a=rtpmap:0 PCMU/8000\r\n\
190                    a=rtpmap:96 telephone-event/8000\r\n";
191        let media = parse_sdp(sdp).unwrap();
192        assert_eq!(media.dtmf_payload_type, Some(96));
193    }
194
195    #[test]
196    fn parse_sdp_ignores_telephone_event_at_wrong_rate() {
197        // RFC 4733 also defines telephone-event/16000 for wideband; we only
198        // ship the 8000 Hz clock domain, so a 16000 entry must not be
199        // selected.
200        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
201                    a=rtpmap:0 PCMU/8000\r\n\
202                    a=rtpmap:101 telephone-event/16000\r\n";
203        let media = parse_sdp(sdp).unwrap();
204        assert_eq!(media.dtmf_payload_type, None);
205    }
206
207    #[test]
208    fn parse_sdp_rejects_telephone_event_not_listed_on_m_line() {
209        // rtpmap names PT 101 but the m=audio line doesn't include it →
210        // not actually negotiated, must be ignored.
211        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0\r\n\
212                    a=rtpmap:0 PCMU/8000\r\n\
213                    a=rtpmap:101 telephone-event/8000\r\n";
214        let media = parse_sdp(sdp).unwrap();
215        assert_eq!(media.dtmf_payload_type, None);
216    }
217
218    #[test]
219    fn parse_sdp_handles_mixed_case_telephone_event() {
220        // Some stacks emit "Telephone-Event" or "TELEPHONE-EVENT". Case
221        // matching at the rtpmap level shouldn't reject those.
222        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
223                    a=rtpmap:0 PCMU/8000\r\n\
224                    a=rtpmap:101 Telephone-Event/8000\r\n";
225        let media = parse_sdp(sdp).unwrap();
226        assert_eq!(media.dtmf_payload_type, Some(101));
227    }
228
229    #[test]
230    fn parse_sdp_handles_telephone_event_without_fmtp() {
231        // `a=fmtp:101 0-15` is optional; many stacks omit it. Absence must
232        // not gate negotiation.
233        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
234                    a=rtpmap:0 PCMU/8000\r\n\
235                    a=rtpmap:101 telephone-event/8000\r\n";
236        let media = parse_sdp(sdp).unwrap();
237        assert_eq!(media.dtmf_payload_type, Some(101));
238    }
239
240    #[test]
241    fn parse_sdp_missing_connection_line() {
242        let sdp = b"v=0\r\nm=audio 20000 RTP/AVP 0\r\n";
243        let err = parse_sdp(sdp).unwrap_err();
244        assert!(err.contains("connection address"));
245    }
246
247    #[test]
248    fn parse_sdp_missing_media_line() {
249        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\n";
250        let err = parse_sdp(sdp).unwrap_err();
251        assert!(err.contains("audio media"));
252    }
253
254    #[test]
255    fn parse_sdp_invalid_utf8() {
256        let sdp = &[0xFF, 0xFE, 0xFD];
257        let err = parse_sdp(sdp).unwrap_err();
258        assert!(err.contains("UTF-8"));
259    }
260}