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/// Media direction (RFC 4566 `a=` attribute), used to put a call on hold
14/// and take it off again per RFC 3264 §8.4.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MediaDirection {
17    /// Send and receive media — the normal, active state (`a=sendrecv`).
18    SendRecv,
19    /// Send only, do not receive — places the peer on hold (`a=sendonly`).
20    SendOnly,
21    /// Neither send nor receive — a full two-way hold (`a=inactive`).
22    Inactive,
23}
24
25impl MediaDirection {
26    /// The `a=` attribute line value (no `a=` prefix, no CRLF).
27    pub fn attribute(&self) -> &'static str {
28        match self {
29            MediaDirection::SendRecv => "sendrecv",
30            MediaDirection::SendOnly => "sendonly",
31            MediaDirection::Inactive => "inactive",
32        }
33    }
34}
35
36/// Build a minimal SDP body for bidirectional G.711 audio with RFC 4733
37/// telephone-event (DTMF) advertised at payload type 101.
38///
39/// Suitable as both the offer (sent in an outbound INVITE) and the answer
40/// (sent in a 200 OK to an inbound INVITE). The G.711 codecs stay
41/// listed first so the remote still selects PCMU/PCMA as the audio
42/// codec; the 101 entry adds DTMF support without changing the
43/// preferred audio choice.
44///
45/// This is the initial offer/answer: `a=sendrecv` with `o=` version 0.
46/// Re-offers (hold/resume) use [`build_sdp_with`].
47pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec<u8> {
48    build_sdp_with(local_ip, rtp_port, MediaDirection::SendRecv, 0)
49}
50
51/// Build the SDP body with an explicit media `direction` and `o=` session
52/// `version`.
53///
54/// RFC 3264 §5 requires every re-offer to keep the same session id but
55/// **increment** the `o=` version; hold/resume re-INVITEs feed the bumped
56/// version here and flip `direction` between [`MediaDirection::SendRecv`]
57/// and [`MediaDirection::SendOnly`]/[`MediaDirection::Inactive`].
58pub fn build_sdp_with(
59    local_ip: IpAddr,
60    rtp_port: u16,
61    direction: MediaDirection,
62    version: u32,
63) -> Vec<u8> {
64    let attr = direction.attribute();
65    format!(
66        "v=0\r\n\
67         o=wavekat 0 {version} IN IP4 {local_ip}\r\n\
68         s=wavekat-sip\r\n\
69         c=IN IP4 {local_ip}\r\n\
70         t=0 0\r\n\
71         m=audio {rtp_port} RTP/AVP 0 8 {DTMF_DEFAULT_PT}\r\n\
72         a=rtpmap:0 PCMU/8000\r\n\
73         a=rtpmap:8 PCMA/8000\r\n\
74         a=rtpmap:{DTMF_DEFAULT_PT} telephone-event/8000\r\n\
75         a=fmtp:{DTMF_DEFAULT_PT} 0-15\r\n\
76         a={attr}\r\n"
77    )
78    .into_bytes()
79}
80
81/// Remote media info extracted from an SDP body.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct RemoteMedia {
84    /// Remote IP from the `c=` line.
85    pub addr: IpAddr,
86    /// Remote RTP port from the `m=audio` line.
87    pub port: u16,
88    /// First (preferred) RTP payload type from the `m=audio` line.
89    pub payload_type: u8,
90    /// Payload type the remote offered (or accepted) for RFC 4733
91    /// `telephone-event/8000`. `None` if the remote did not advertise
92    /// it — consumers should fall back to SIP INFO for DTMF in that
93    /// case.
94    pub dtmf_payload_type: Option<u8>,
95}
96
97/// Parse the connection address, audio port, preferred codec, and
98/// (optionally) the negotiated DTMF payload type from an SDP body.
99pub fn parse_sdp(sdp_bytes: &[u8]) -> Result<RemoteMedia, String> {
100    let sdp = std::str::from_utf8(sdp_bytes).map_err(|e| format!("SDP not UTF-8: {e}"))?;
101
102    let mut addr: Option<IpAddr> = None;
103    let mut port: Option<u16> = None;
104    let mut audio_pts: Vec<u8> = Vec::new();
105    // PTs whose rtpmap names them as `telephone-event/8000`.
106    let mut dtmf_candidate_pts: Vec<u8> = Vec::new();
107
108    for line in sdp.lines() {
109        let line = line.trim();
110
111        // c=IN IP4 10.0.0.1
112        if line.starts_with("c=IN IP4 ") || line.starts_with("c=IN IP6 ") {
113            if let Some(ip_str) = line.split_whitespace().nth(2) {
114                addr = ip_str.parse().ok();
115            }
116        }
117
118        // m=audio 20000 RTP/AVP 0 8 101
119        // The first payload type after RTP/AVP is the preferred codec;
120        // the rest may include telephone-event.
121        if line.starts_with("m=audio ") {
122            let parts: Vec<&str> = line.split_whitespace().collect();
123            if parts.len() >= 2 {
124                port = parts[1].parse().ok();
125            }
126            // Skip "m=audio", port, "RTP/AVP" — payload types start at index 3.
127            for pt_str in parts.iter().skip(3) {
128                if let Ok(pt) = pt_str.parse::<u8>() {
129                    audio_pts.push(pt);
130                }
131            }
132        }
133
134        // a=rtpmap:101 telephone-event/8000
135        if let Some(rest) = line.strip_prefix("a=rtpmap:") {
136            // <pt> <name>/<rate>[/params]
137            let mut sp = rest.splitn(2, char::is_whitespace);
138            let pt_str = sp.next().unwrap_or("");
139            let mapping = sp.next().unwrap_or("");
140            if let Ok(pt) = pt_str.parse::<u8>() {
141                let name_lc = mapping.split('/').next().unwrap_or("").to_ascii_lowercase();
142                let rate = mapping.split('/').nth(1).unwrap_or("");
143                if name_lc == "telephone-event" && rate == "8000" {
144                    dtmf_candidate_pts.push(pt);
145                }
146            }
147        }
148    }
149
150    // DTMF must both be named in an rtpmap *and* listed on the m=audio line
151    // for the negotiation to be valid. If either is missing, treat DTMF as
152    // not negotiated.
153    let dtmf_payload_type = dtmf_candidate_pts
154        .into_iter()
155        .find(|pt| audio_pts.contains(pt));
156
157    let payload_type = audio_pts.first().copied();
158
159    match (addr, port, payload_type) {
160        (Some(addr), Some(port), Some(pt)) => Ok(RemoteMedia {
161            addr,
162            port,
163            payload_type: pt,
164            dtmf_payload_type,
165        }),
166        (None, _, _) => Err("No connection address (c=) in SDP".to_string()),
167        (_, None, _) => Err("No audio media line (m=audio) in SDP".to_string()),
168        (_, _, None) => Err("No payload type in m=audio line".to_string()),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::net::Ipv4Addr;
176
177    #[test]
178    fn build_sdp_contains_required_fields() {
179        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
180        let sdp = build_sdp(ip, 5004);
181        let text = String::from_utf8(sdp).unwrap();
182
183        assert!(text.contains("v=0\r\n"));
184        assert!(text.contains("c=IN IP4 10.0.0.1\r\n"));
185        assert!(text.contains("m=audio 5004 RTP/AVP 0 8 101\r\n"));
186        assert!(text.contains("a=sendrecv\r\n"));
187        assert!(text.contains("a=rtpmap:0 PCMU/8000\r\n"));
188        assert!(text.contains("a=rtpmap:8 PCMA/8000\r\n"));
189        assert!(text.contains("a=rtpmap:101 telephone-event/8000\r\n"));
190        assert!(text.contains("a=fmtp:101 0-15\r\n"));
191    }
192
193    #[test]
194    fn build_sdp_with_sets_direction_and_version() {
195        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
196
197        let hold =
198            String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::SendOnly, 1)).unwrap();
199        assert!(hold.contains("o=wavekat 0 1 IN IP4 10.0.0.1\r\n"));
200        assert!(hold.contains("a=sendonly\r\n"));
201        assert!(!hold.contains("a=sendrecv\r\n"));
202
203        let resume =
204            String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::SendRecv, 2)).unwrap();
205        assert!(resume.contains("o=wavekat 0 2 IN IP4 10.0.0.1\r\n"));
206        assert!(resume.contains("a=sendrecv\r\n"));
207
208        let inactive =
209            String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::Inactive, 3)).unwrap();
210        assert!(inactive.contains("a=inactive\r\n"));
211    }
212
213    #[test]
214    fn build_sdp_is_sendrecv_version_zero() {
215        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
216        let base = String::from_utf8(build_sdp(ip, 5004)).unwrap();
217        assert!(base.contains("o=wavekat 0 0 IN IP4 10.0.0.1\r\n"));
218        assert!(base.contains("a=sendrecv\r\n"));
219    }
220
221    #[test]
222    fn parse_sdp_extracts_addr_port_and_codec() {
223        let sdp = b"v=0\r\nc=IN IP4 192.168.1.100\r\nm=audio 20000 RTP/AVP 0\r\n";
224        let media = parse_sdp(sdp).unwrap();
225        assert_eq!(media.addr, Ipv4Addr::new(192, 168, 1, 100));
226        assert_eq!(media.port, 20000);
227        assert_eq!(media.payload_type, 0);
228        // No rtpmap for telephone-event → no DTMF advertised.
229        assert_eq!(media.dtmf_payload_type, None);
230    }
231
232    #[test]
233    fn parse_sdp_extracts_first_codec_when_multiple() {
234        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 8 0 101\r\n\
235                    a=rtpmap:101 telephone-event/8000\r\n";
236        let media = parse_sdp(sdp).unwrap();
237        // First PT in the m= line is still the preferred audio codec — adding
238        // telephone-event must not change which codec the consumer thinks the
239        // remote wants for voice.
240        assert_eq!(media.payload_type, 8); // PCMA is first/preferred
241        assert_eq!(media.dtmf_payload_type, Some(101));
242    }
243
244    #[test]
245    fn round_trip_build_then_parse() {
246        let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5));
247        let sdp = build_sdp(ip, 8000);
248        let media = parse_sdp(&sdp).unwrap();
249        assert_eq!(media.addr, ip);
250        assert_eq!(media.port, 8000);
251        assert_eq!(media.payload_type, 0); // PCMU is first in our SDP
252        assert_eq!(media.dtmf_payload_type, Some(DTMF_DEFAULT_PT));
253    }
254
255    #[test]
256    fn parse_sdp_picks_dtmf_pt_from_rtpmap_not_constant() {
257        // Some remotes negotiate telephone-event on a different dynamic PT
258        // (96-127). The parser must follow the rtpmap, not assume 101.
259        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 96\r\n\
260                    a=rtpmap:0 PCMU/8000\r\n\
261                    a=rtpmap:96 telephone-event/8000\r\n";
262        let media = parse_sdp(sdp).unwrap();
263        assert_eq!(media.dtmf_payload_type, Some(96));
264    }
265
266    #[test]
267    fn parse_sdp_ignores_telephone_event_at_wrong_rate() {
268        // RFC 4733 also defines telephone-event/16000 for wideband; we only
269        // ship the 8000 Hz clock domain, so a 16000 entry must not be
270        // selected.
271        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
272                    a=rtpmap:0 PCMU/8000\r\n\
273                    a=rtpmap:101 telephone-event/16000\r\n";
274        let media = parse_sdp(sdp).unwrap();
275        assert_eq!(media.dtmf_payload_type, None);
276    }
277
278    #[test]
279    fn parse_sdp_rejects_telephone_event_not_listed_on_m_line() {
280        // rtpmap names PT 101 but the m=audio line doesn't include it →
281        // not actually negotiated, must be ignored.
282        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0\r\n\
283                    a=rtpmap:0 PCMU/8000\r\n\
284                    a=rtpmap:101 telephone-event/8000\r\n";
285        let media = parse_sdp(sdp).unwrap();
286        assert_eq!(media.dtmf_payload_type, None);
287    }
288
289    #[test]
290    fn parse_sdp_handles_mixed_case_telephone_event() {
291        // Some stacks emit "Telephone-Event" or "TELEPHONE-EVENT". Case
292        // matching at the rtpmap level shouldn't reject those.
293        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
294                    a=rtpmap:0 PCMU/8000\r\n\
295                    a=rtpmap:101 Telephone-Event/8000\r\n";
296        let media = parse_sdp(sdp).unwrap();
297        assert_eq!(media.dtmf_payload_type, Some(101));
298    }
299
300    #[test]
301    fn parse_sdp_handles_telephone_event_without_fmtp() {
302        // `a=fmtp:101 0-15` is optional; many stacks omit it. Absence must
303        // not gate negotiation.
304        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 0 101\r\n\
305                    a=rtpmap:0 PCMU/8000\r\n\
306                    a=rtpmap:101 telephone-event/8000\r\n";
307        let media = parse_sdp(sdp).unwrap();
308        assert_eq!(media.dtmf_payload_type, Some(101));
309    }
310
311    #[test]
312    fn parse_sdp_missing_connection_line() {
313        let sdp = b"v=0\r\nm=audio 20000 RTP/AVP 0\r\n";
314        let err = parse_sdp(sdp).unwrap_err();
315        assert!(err.contains("connection address"));
316    }
317
318    #[test]
319    fn parse_sdp_missing_media_line() {
320        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\n";
321        let err = parse_sdp(sdp).unwrap_err();
322        assert!(err.contains("audio media"));
323    }
324
325    #[test]
326    fn parse_sdp_invalid_utf8() {
327        let sdp = &[0xFF, 0xFE, 0xFD];
328        let err = parse_sdp(sdp).unwrap_err();
329        assert!(err.contains("UTF-8"));
330    }
331}