Skip to main content

wavekat_sip/
sdp.rs

1//! Minimal SDP offer/answer for G.711 telephony audio.
2
3use std::net::IpAddr;
4
5/// Build a minimal SDP body for bidirectional audio (G.711 PCMU + PCMA).
6///
7/// Suitable as both the offer (sent in an outbound INVITE) and the answer
8/// (sent in a 200 OK to an inbound INVITE).
9pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec<u8> {
10    format!(
11        "v=0\r\n\
12         o=wavekat 0 0 IN IP4 {local_ip}\r\n\
13         s=wavekat-sip\r\n\
14         c=IN IP4 {local_ip}\r\n\
15         t=0 0\r\n\
16         m=audio {rtp_port} RTP/AVP 0 8\r\n\
17         a=rtpmap:0 PCMU/8000\r\n\
18         a=rtpmap:8 PCMA/8000\r\n\
19         a=sendrecv\r\n"
20    )
21    .into_bytes()
22}
23
24/// Remote media info extracted from an SDP body.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct RemoteMedia {
27    /// Remote IP from the `c=` line.
28    pub addr: IpAddr,
29    /// Remote RTP port from the `m=audio` line.
30    pub port: u16,
31    /// First (preferred) RTP payload type from the `m=audio` line.
32    pub payload_type: u8,
33}
34
35/// Parse the connection address, audio port, and preferred codec from an
36/// SDP body.
37pub fn parse_sdp(sdp_bytes: &[u8]) -> Result<RemoteMedia, String> {
38    let sdp = std::str::from_utf8(sdp_bytes).map_err(|e| format!("SDP not UTF-8: {e}"))?;
39
40    let mut addr: Option<IpAddr> = None;
41    let mut port: Option<u16> = None;
42    let mut payload_type: Option<u8> = None;
43
44    for line in sdp.lines() {
45        let line = line.trim();
46
47        // c=IN IP4 10.0.0.1
48        if line.starts_with("c=IN IP4 ") || line.starts_with("c=IN IP6 ") {
49            if let Some(ip_str) = line.split_whitespace().nth(2) {
50                addr = ip_str.parse().ok();
51            }
52        }
53
54        // m=audio 20000 RTP/AVP 0 8
55        // The first payload type after RTP/AVP is the preferred codec.
56        if line.starts_with("m=audio ") {
57            let parts: Vec<&str> = line.split_whitespace().collect();
58            if parts.len() >= 2 {
59                port = parts[1].parse().ok();
60            }
61            if parts.len() >= 4 {
62                payload_type = parts[3].parse().ok();
63            }
64        }
65    }
66
67    match (addr, port, payload_type) {
68        (Some(addr), Some(port), Some(pt)) => Ok(RemoteMedia {
69            addr,
70            port,
71            payload_type: pt,
72        }),
73        (None, _, _) => Err("No connection address (c=) in SDP".to_string()),
74        (_, None, _) => Err("No audio media line (m=audio) in SDP".to_string()),
75        (_, _, None) => Err("No payload type in m=audio line".to_string()),
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::net::Ipv4Addr;
83
84    #[test]
85    fn build_sdp_contains_required_fields() {
86        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
87        let sdp = build_sdp(ip, 5004);
88        let text = String::from_utf8(sdp).unwrap();
89
90        assert!(text.contains("v=0\r\n"));
91        assert!(text.contains("c=IN IP4 10.0.0.1\r\n"));
92        assert!(text.contains("m=audio 5004 RTP/AVP 0 8\r\n"));
93        assert!(text.contains("a=sendrecv\r\n"));
94        assert!(text.contains("a=rtpmap:0 PCMU/8000\r\n"));
95        assert!(text.contains("a=rtpmap:8 PCMA/8000\r\n"));
96    }
97
98    #[test]
99    fn parse_sdp_extracts_addr_port_and_codec() {
100        let sdp = b"v=0\r\nc=IN IP4 192.168.1.100\r\nm=audio 20000 RTP/AVP 0\r\n";
101        let media = parse_sdp(sdp).unwrap();
102        assert_eq!(media.addr, Ipv4Addr::new(192, 168, 1, 100));
103        assert_eq!(media.port, 20000);
104        assert_eq!(media.payload_type, 0); // PCMU
105    }
106
107    #[test]
108    fn parse_sdp_extracts_first_codec_when_multiple() {
109        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5000 RTP/AVP 8 0 101\r\n";
110        let media = parse_sdp(sdp).unwrap();
111        assert_eq!(media.payload_type, 8); // PCMA is first/preferred
112    }
113
114    #[test]
115    fn round_trip_build_then_parse() {
116        let ip = IpAddr::V4(Ipv4Addr::new(172, 16, 0, 5));
117        let sdp = build_sdp(ip, 8000);
118        let media = parse_sdp(&sdp).unwrap();
119        assert_eq!(media.addr, ip);
120        assert_eq!(media.port, 8000);
121        assert_eq!(media.payload_type, 0); // PCMU is first in our SDP
122    }
123
124    #[test]
125    fn parse_sdp_missing_connection_line() {
126        let sdp = b"v=0\r\nm=audio 20000 RTP/AVP 0\r\n";
127        let err = parse_sdp(sdp).unwrap_err();
128        assert!(err.contains("connection address"));
129    }
130
131    #[test]
132    fn parse_sdp_missing_media_line() {
133        let sdp = b"v=0\r\nc=IN IP4 10.0.0.1\r\n";
134        let err = parse_sdp(sdp).unwrap_err();
135        assert!(err.contains("audio media"));
136    }
137
138    #[test]
139    fn parse_sdp_invalid_utf8() {
140        let sdp = &[0xFF, 0xFE, 0xFD];
141        let err = parse_sdp(sdp).unwrap_err();
142        assert!(err.contains("UTF-8"));
143    }
144}