gemini_adk_fluent_rs/telephony/
sdp.rs1use std::fmt::Write as _;
14
15use super::rtp::{PT_PCMA, PT_PCMU};
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct AudioOffer {
20 pub host: String,
22 pub port: u16,
24 pub payload_types: Vec<u8>,
26 pub telephone_event_pt: Option<u8>,
30 pub secure: bool,
33 pub crypto: Vec<String>,
36}
37
38impl AudioOffer {
39 pub fn g711_payload_type(&self) -> Option<u8> {
42 self.payload_types
43 .iter()
44 .copied()
45 .find(|&pt| pt == PT_PCMU || pt == PT_PCMA)
46 }
47}
48
49pub fn parse_audio_offer(sdp: &str) -> Option<AudioOffer> {
54 let mut session_host: Option<String> = None;
55 let mut audio: Option<(u16, Vec<u8>)> = None;
56 let mut media_host: Option<String> = None;
57 let mut telephone_event_pt: Option<u8> = None;
58 let mut secure = false;
59 let mut crypto = Vec::new();
60 let mut in_audio = false;
61
62 for line in sdp.lines() {
63 let line = line.trim_end();
64 if let Some(rest) = line.strip_prefix("c=") {
65 let host = rest.split_whitespace().nth(2)?.to_string();
67 if in_audio {
68 media_host = Some(host);
69 } else if session_host.is_none() {
70 session_host = Some(host);
71 }
72 } else if let Some(rest) = line.strip_prefix("a=crypto:") {
73 if in_audio {
74 crypto.push(rest.trim().to_string());
75 }
76 } else if let Some(rest) = line.strip_prefix("a=rtpmap:") {
77 if in_audio && telephone_event_pt.is_none() {
79 let mut parts = rest.split_whitespace();
80 if let (Some(pt), Some(codec)) = (parts.next(), parts.next())
81 && codec.eq_ignore_ascii_case("telephone-event/8000")
82 {
83 telephone_event_pt = pt.parse().ok();
84 }
85 }
86 } else if let Some(rest) = line.strip_prefix("m=") {
87 let mut parts = rest.split_whitespace();
88 let kind = parts.next()?;
89 if kind == "audio" && audio.is_none() {
90 in_audio = true;
91 let port: u16 = parts.next()?.parse().ok()?;
92 secure = parts.next()?.eq_ignore_ascii_case("RTP/SAVP");
94 let payload_types = parts.filter_map(|p| p.parse().ok()).collect();
95 audio = Some((port, payload_types));
96 } else {
97 in_audio = false; }
99 }
100 }
101
102 let (port, payload_types) = audio?;
103 if port == 0 {
104 return None; }
106 let host = media_host.or(session_host)?;
107 let telephone_event_pt = telephone_event_pt.filter(|pt| payload_types.contains(pt));
109 Some(AudioOffer {
110 host,
111 port,
112 payload_types,
113 telephone_event_pt,
114 secure,
115 crypto,
116 })
117}
118
119pub fn audio_answer(
125 session_id: u64,
126 host: &str,
127 port: u16,
128 payload_type: u8,
129 telephone_event_pt: Option<u8>,
130) -> String {
131 answer(
132 session_id,
133 host,
134 port,
135 payload_type,
136 telephone_event_pt,
137 None,
138 )
139}
140
141pub fn secure_audio_answer(
145 session_id: u64,
146 host: &str,
147 port: u16,
148 payload_type: u8,
149 telephone_event_pt: Option<u8>,
150 crypto: &str,
151) -> String {
152 answer(
153 session_id,
154 host,
155 port,
156 payload_type,
157 telephone_event_pt,
158 Some(crypto),
159 )
160}
161
162fn answer(
163 session_id: u64,
164 host: &str,
165 port: u16,
166 payload_type: u8,
167 telephone_event_pt: Option<u8>,
168 crypto: Option<&str>,
169) -> String {
170 let profile = if crypto.is_some() {
171 "RTP/SAVP"
172 } else {
173 "RTP/AVP"
174 };
175 let codec_name = if payload_type == PT_PCMA {
176 "PCMA"
177 } else {
178 "PCMU"
179 };
180 let mut out = String::new();
181 let _ = writeln!(out, "v=0");
182 let _ = writeln!(out, "o=- {session_id} {session_id} IN IP4 {host}");
183 let _ = writeln!(out, "s=gemini-rs");
184 let _ = writeln!(out, "c=IN IP4 {host}");
185 let _ = writeln!(out, "t=0 0");
186 match telephone_event_pt {
187 Some(te) => {
188 let _ = writeln!(out, "m=audio {port} {profile} {payload_type} {te}");
189 let _ = writeln!(out, "a=rtpmap:{payload_type} {codec_name}/8000");
190 let _ = writeln!(out, "a=rtpmap:{te} telephone-event/8000");
191 let _ = writeln!(out, "a=fmtp:{te} 0-15");
192 }
193 None => {
194 let _ = writeln!(out, "m=audio {port} {profile} {payload_type}");
195 let _ = writeln!(out, "a=rtpmap:{payload_type} {codec_name}/8000");
196 }
197 }
198 if let Some(crypto) = crypto {
199 let _ = writeln!(out, "a=crypto:{crypto}");
200 }
201 let _ = writeln!(out, "a=ptime:20");
202 let _ = writeln!(out, "a=sendrecv");
203 out.replace('\n', "\r\n")
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 const OFFER: &str = "v=0\r\n\
212 o=alice 2890844526 2890844526 IN IP4 198.51.100.1\r\n\
213 s=call\r\n\
214 c=IN IP4 198.51.100.1\r\n\
215 t=0 0\r\n\
216 m=audio 49170 RTP/AVP 8 0 101\r\n\
217 a=rtpmap:8 PCMA/8000\r\n\
218 a=rtpmap:0 PCMU/8000\r\n\
219 a=rtpmap:101 telephone-event/8000\r\n";
220
221 #[test]
222 fn parses_a_typical_softphone_offer() {
223 let offer = parse_audio_offer(OFFER).expect("parses");
224 assert_eq!(offer.host, "198.51.100.1");
225 assert_eq!(offer.port, 49170);
226 assert_eq!(offer.payload_types, vec![8, 0, 101]);
227 assert_eq!(offer.g711_payload_type(), Some(PT_PCMA));
229 assert_eq!(offer.telephone_event_pt, Some(101));
231 }
232
233 #[test]
234 fn telephone_event_requires_the_media_line_to_list_it() {
235 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\n\
237 a=rtpmap:101 telephone-event/8000\r\n";
238 assert_eq!(parse_audio_offer(sdp).unwrap().telephone_event_pt, None);
239 }
240
241 #[test]
242 fn media_level_connection_overrides_session_level() {
243 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\nc=IN IP4 192.0.2.99\r\n";
244 assert_eq!(parse_audio_offer(sdp).unwrap().host, "192.0.2.99");
245 }
246
247 #[test]
248 fn rejects_offers_without_usable_audio() {
249 assert_eq!(parse_audio_offer("v=0\r\ns=x\r\n"), None);
250 assert_eq!(
252 parse_audio_offer("v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 0 RTP/AVP 0\r\n"),
253 None
254 );
255 assert_eq!(
257 parse_audio_offer("v=0\r\nc=IN IP4 192.0.2.1\r\nm=video 5000 RTP/AVP 96\r\n"),
258 None
259 );
260 }
261
262 #[test]
263 fn no_g711_means_no_answerable_codec() {
264 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 96 97\r\n";
265 assert_eq!(
266 parse_audio_offer(sdp).unwrap().g711_payload_type(),
267 None,
268 "opus-only offers are not answerable by a G.711 agent"
269 );
270 }
271
272 #[test]
273 fn an_savp_offer_is_secure_and_answered_securely() {
274 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/SAVP 0\r\n\
275 a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:AAAA\r\n\
276 a=crypto:2 AES_CM_128_HMAC_SHA1_32 inline:BBBB\r\n";
277 let offer = parse_audio_offer(sdp).unwrap();
278 assert!(offer.secure);
279 assert_eq!(
280 offer.crypto,
281 [
282 "1 AES_CM_128_HMAC_SHA1_80 inline:AAAA",
283 "2 AES_CM_128_HMAC_SHA1_32 inline:BBBB"
284 ]
285 );
286 let plain =
287 parse_audio_offer("v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\n").unwrap();
288 assert!(!plain.secure && plain.crypto.is_empty());
289
290 let answer = secure_audio_answer(
291 1,
292 "192.0.2.5",
293 40000,
294 0,
295 None,
296 "1 AES_CM_128_HMAC_SHA1_80 inline:CCCC",
297 );
298 assert!(answer.contains("m=audio 40000 RTP/SAVP 0\r\n"), "{answer}");
299 assert!(answer.contains("a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:CCCC\r\n"));
300 }
301
302 #[test]
303 fn answer_is_wellformed_and_crlf_terminated() {
304 let answer = audio_answer(7, "203.0.113.9", 40_000, PT_PCMU, None);
305 assert!(answer.contains("m=audio 40000 RTP/AVP 0\r\n"));
306 assert!(answer.contains("a=rtpmap:0 PCMU/8000\r\n"));
307 assert!(answer.contains("c=IN IP4 203.0.113.9\r\n"));
308 assert!(!answer.contains("\n\n"));
309 assert!(!answer.contains("telephone-event"));
310 let parsed = parse_audio_offer(&answer).unwrap();
312 assert_eq!(parsed.port, 40_000);
313 assert_eq!(parsed.g711_payload_type(), Some(PT_PCMU));
314 }
315
316 #[test]
317 fn answer_echoes_telephone_event_negotiation() {
318 let answer = audio_answer(7, "203.0.113.9", 40_000, PT_PCMU, Some(101));
319 assert!(answer.contains("m=audio 40000 RTP/AVP 0 101\r\n"));
320 assert!(answer.contains("a=rtpmap:101 telephone-event/8000\r\n"));
321 assert!(answer.contains("a=fmtp:101 0-15\r\n"));
322 let parsed = parse_audio_offer(&answer).unwrap();
324 assert_eq!(parsed.telephone_event_pt, Some(101));
325 assert_eq!(parsed.g711_payload_type(), Some(PT_PCMU));
326 }
327
328 #[test]
329 fn parses_ipv6_address_in_connection_line() {
330 let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nm=audio 4000 RTP/AVP 0\r\n";
332 let offer = parse_audio_offer(sdp).expect("should parse IPv6");
333 assert_eq!(offer.host, "2001:db8::1");
334 }
335
336 #[test]
337 fn multiple_media_sections_uses_first_audio() {
338 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=video 5000 RTP/AVP 96\r\n\
340 m=audio 4000 RTP/AVP 0\r\n";
341 let offer = parse_audio_offer(sdp).expect("should find first audio after video");
342 assert_eq!(offer.port, 4000);
343 }
344
345 #[test]
346 fn connection_line_after_audio_section_starts_ignored() {
347 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=video 5000 RTP/AVP 96\r\nc=IN IP4 192.0.2.99\r\n\
349 m=audio 4000 RTP/AVP 0\r\n";
350 let offer = parse_audio_offer(sdp).expect("should parse correctly");
351 assert_eq!(offer.host, "192.0.2.1");
353 }
354
355 #[test]
356 fn session_level_connection_used_when_media_level_missing() {
357 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\n";
359 let offer = parse_audio_offer(sdp).expect("should use session-level connection");
360 assert_eq!(offer.host, "192.0.2.1");
361 }
362
363 #[test]
364 fn payload_type_with_whitespace() {
365 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0 8 101 \r\n";
367 let offer = parse_audio_offer(sdp).expect("should handle extra whitespace");
368 assert_eq!(offer.payload_types, vec![0, 8, 101]);
369 }
370
371 #[test]
372 fn port_number_edge_cases() {
373 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 1 RTP/AVP 0\r\n";
375 let offer = parse_audio_offer(sdp).expect("should accept port 1");
376 assert_eq!(offer.port, 1);
377
378 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 65535 RTP/AVP 0\r\n";
380 let offer = parse_audio_offer(sdp).expect("should accept max port");
381 assert_eq!(offer.port, 65535);
382 }
383
384 #[test]
385 fn invalid_port_number_rejected() {
386 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio abc RTP/AVP 0\r\n";
388 assert_eq!(parse_audio_offer(sdp), None);
389
390 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 70000 RTP/AVP 0\r\n";
392 assert_eq!(parse_audio_offer(sdp), None);
393 }
394
395 #[test]
396 fn rtpmap_with_extra_fields() {
397 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 101\r\n\
399 a=rtpmap:101 telephone-event/8000 extra stuff\r\n";
400 let offer = parse_audio_offer(sdp).expect("should parse despite extra fields");
401 assert_eq!(offer.telephone_event_pt, Some(101));
402 }
403
404 #[test]
405 fn answer_with_ipv6_address() {
406 let answer = audio_answer(7, "2001:db8::1", 4000, PT_PCMU, None);
408 assert!(
409 answer.contains("c=IN IP4 2001:db8::1\r\n"),
410 "IPv6 in output"
411 );
412 let parsed = parse_audio_offer(&answer).expect("should parse answer with IPv6");
414 assert_eq!(parsed.host, "2001:db8::1");
415 }
416
417 #[test]
418 fn answer_session_id_is_version() {
419 let answer = audio_answer(12345, "192.0.2.1", 4000, PT_PCMU, None);
421 assert!(
422 answer.contains("o=- 12345 12345 IN IP4"),
423 "session_id used for both fields"
424 );
425 }
426
427 #[test]
428 fn case_insensitive_telephone_event_match() {
429 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 101\r\n\
431 a=rtpmap:101 TELEPHONE-EVENT/8000\r\n";
432 let offer = parse_audio_offer(sdp).expect("should parse");
433 assert_eq!(
434 offer.telephone_event_pt,
435 Some(101),
436 "case-insensitive match"
437 );
438 }
439
440 #[test]
441 fn empty_sdp_rejected() {
442 assert_eq!(parse_audio_offer(""), None);
443 }
444
445 #[test]
446 fn sdp_without_media_section_rejected() {
447 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\ns=no media\r\n";
448 assert_eq!(parse_audio_offer(sdp), None);
449 }
450}