1use std::net::IpAddr;
5
6pub const DTMF_DEFAULT_PT: u8 = 101;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MediaDirection {
17 SendRecv,
19 SendOnly,
21 Inactive,
23}
24
25impl MediaDirection {
26 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
36pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec<u8> {
48 build_sdp_with(local_ip, rtp_port, MediaDirection::SendRecv, 0)
49}
50
51pub 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#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct RemoteMedia {
84 pub addr: IpAddr,
86 pub port: u16,
88 pub payload_type: u8,
90 pub dtmf_payload_type: Option<u8>,
95}
96
97pub 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 let mut dtmf_candidate_pts: Vec<u8> = Vec::new();
107
108 for line in sdp.lines() {
109 let line = line.trim();
110
111 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 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 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 if let Some(rest) = line.strip_prefix("a=rtpmap:") {
136 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 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 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 assert_eq!(media.payload_type, 8); 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); 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 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 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 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 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 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}