gemini_adk_fluent_rs/telephony/
sdp.rs1use std::fmt::Write as _;
12
13use super::rtp::{PT_PCMA, PT_PCMU};
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct AudioOffer {
18 pub host: String,
20 pub port: u16,
22 pub payload_types: Vec<u8>,
24 pub telephone_event_pt: Option<u8>,
28}
29
30impl AudioOffer {
31 pub fn g711_payload_type(&self) -> Option<u8> {
34 self.payload_types
35 .iter()
36 .copied()
37 .find(|&pt| pt == PT_PCMU || pt == PT_PCMA)
38 }
39}
40
41pub fn parse_audio_offer(sdp: &str) -> Option<AudioOffer> {
46 let mut session_host: Option<String> = None;
47 let mut audio: Option<(u16, Vec<u8>)> = None;
48 let mut media_host: Option<String> = None;
49 let mut telephone_event_pt: Option<u8> = None;
50 let mut in_audio = false;
51
52 for line in sdp.lines() {
53 let line = line.trim_end();
54 if let Some(rest) = line.strip_prefix("c=") {
55 let host = rest.split_whitespace().nth(2)?.to_string();
57 if in_audio {
58 media_host = Some(host);
59 } else if session_host.is_none() {
60 session_host = Some(host);
61 }
62 } else if let Some(rest) = line.strip_prefix("a=rtpmap:") {
63 if in_audio && telephone_event_pt.is_none() {
65 let mut parts = rest.split_whitespace();
66 if let (Some(pt), Some(codec)) = (parts.next(), parts.next())
67 && codec.eq_ignore_ascii_case("telephone-event/8000")
68 {
69 telephone_event_pt = pt.parse().ok();
70 }
71 }
72 } else if let Some(rest) = line.strip_prefix("m=") {
73 let mut parts = rest.split_whitespace();
74 let kind = parts.next()?;
75 if kind == "audio" && audio.is_none() {
76 in_audio = true;
77 let port: u16 = parts.next()?.parse().ok()?;
78 let _proto = parts.next()?; let payload_types = parts.filter_map(|p| p.parse().ok()).collect();
80 audio = Some((port, payload_types));
81 } else {
82 in_audio = false; }
84 }
85 }
86
87 let (port, payload_types) = audio?;
88 if port == 0 {
89 return None; }
91 let host = media_host.or(session_host)?;
92 let telephone_event_pt = telephone_event_pt.filter(|pt| payload_types.contains(pt));
94 Some(AudioOffer {
95 host,
96 port,
97 payload_types,
98 telephone_event_pt,
99 })
100}
101
102pub fn audio_answer(
108 session_id: u64,
109 host: &str,
110 port: u16,
111 payload_type: u8,
112 telephone_event_pt: Option<u8>,
113) -> String {
114 let codec_name = if payload_type == PT_PCMA {
115 "PCMA"
116 } else {
117 "PCMU"
118 };
119 let mut out = String::new();
120 let _ = writeln!(out, "v=0");
121 let _ = writeln!(out, "o=- {session_id} {session_id} IN IP4 {host}");
122 let _ = writeln!(out, "s=gemini-rs");
123 let _ = writeln!(out, "c=IN IP4 {host}");
124 let _ = writeln!(out, "t=0 0");
125 match telephone_event_pt {
126 Some(te) => {
127 let _ = writeln!(out, "m=audio {port} RTP/AVP {payload_type} {te}");
128 let _ = writeln!(out, "a=rtpmap:{payload_type} {codec_name}/8000");
129 let _ = writeln!(out, "a=rtpmap:{te} telephone-event/8000");
130 let _ = writeln!(out, "a=fmtp:{te} 0-15");
131 }
132 None => {
133 let _ = writeln!(out, "m=audio {port} RTP/AVP {payload_type}");
134 let _ = writeln!(out, "a=rtpmap:{payload_type} {codec_name}/8000");
135 }
136 }
137 let _ = writeln!(out, "a=ptime:20");
138 let _ = writeln!(out, "a=sendrecv");
139 out.replace('\n', "\r\n")
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 const OFFER: &str = "v=0\r\n\
148 o=alice 2890844526 2890844526 IN IP4 198.51.100.1\r\n\
149 s=call\r\n\
150 c=IN IP4 198.51.100.1\r\n\
151 t=0 0\r\n\
152 m=audio 49170 RTP/AVP 8 0 101\r\n\
153 a=rtpmap:8 PCMA/8000\r\n\
154 a=rtpmap:0 PCMU/8000\r\n\
155 a=rtpmap:101 telephone-event/8000\r\n";
156
157 #[test]
158 fn parses_a_typical_softphone_offer() {
159 let offer = parse_audio_offer(OFFER).expect("parses");
160 assert_eq!(offer.host, "198.51.100.1");
161 assert_eq!(offer.port, 49170);
162 assert_eq!(offer.payload_types, vec![8, 0, 101]);
163 assert_eq!(offer.g711_payload_type(), Some(PT_PCMA));
165 assert_eq!(offer.telephone_event_pt, Some(101));
167 }
168
169 #[test]
170 fn telephone_event_requires_the_media_line_to_list_it() {
171 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\n\
173 a=rtpmap:101 telephone-event/8000\r\n";
174 assert_eq!(parse_audio_offer(sdp).unwrap().telephone_event_pt, None);
175 }
176
177 #[test]
178 fn media_level_connection_overrides_session_level() {
179 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";
180 assert_eq!(parse_audio_offer(sdp).unwrap().host, "192.0.2.99");
181 }
182
183 #[test]
184 fn rejects_offers_without_usable_audio() {
185 assert_eq!(parse_audio_offer("v=0\r\ns=x\r\n"), None);
186 assert_eq!(
188 parse_audio_offer("v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 0 RTP/AVP 0\r\n"),
189 None
190 );
191 assert_eq!(
193 parse_audio_offer("v=0\r\nc=IN IP4 192.0.2.1\r\nm=video 5000 RTP/AVP 96\r\n"),
194 None
195 );
196 }
197
198 #[test]
199 fn no_g711_means_no_answerable_codec() {
200 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 96 97\r\n";
201 assert_eq!(
202 parse_audio_offer(sdp).unwrap().g711_payload_type(),
203 None,
204 "opus-only offers are not answerable by a G.711 agent"
205 );
206 }
207
208 #[test]
209 fn answer_is_wellformed_and_crlf_terminated() {
210 let answer = audio_answer(7, "203.0.113.9", 40_000, PT_PCMU, None);
211 assert!(answer.contains("m=audio 40000 RTP/AVP 0\r\n"));
212 assert!(answer.contains("a=rtpmap:0 PCMU/8000\r\n"));
213 assert!(answer.contains("c=IN IP4 203.0.113.9\r\n"));
214 assert!(!answer.contains("\n\n"));
215 assert!(!answer.contains("telephone-event"));
216 let parsed = parse_audio_offer(&answer).unwrap();
218 assert_eq!(parsed.port, 40_000);
219 assert_eq!(parsed.g711_payload_type(), Some(PT_PCMU));
220 }
221
222 #[test]
223 fn answer_echoes_telephone_event_negotiation() {
224 let answer = audio_answer(7, "203.0.113.9", 40_000, PT_PCMU, Some(101));
225 assert!(answer.contains("m=audio 40000 RTP/AVP 0 101\r\n"));
226 assert!(answer.contains("a=rtpmap:101 telephone-event/8000\r\n"));
227 assert!(answer.contains("a=fmtp:101 0-15\r\n"));
228 let parsed = parse_audio_offer(&answer).unwrap();
230 assert_eq!(parsed.telephone_event_pt, Some(101));
231 assert_eq!(parsed.g711_payload_type(), Some(PT_PCMU));
232 }
233
234 #[test]
235 fn parses_ipv6_address_in_connection_line() {
236 let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nm=audio 4000 RTP/AVP 0\r\n";
238 let offer = parse_audio_offer(sdp).expect("should parse IPv6");
239 assert_eq!(offer.host, "2001:db8::1");
240 }
241
242 #[test]
243 fn multiple_media_sections_uses_first_audio() {
244 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=video 5000 RTP/AVP 96\r\n\
246 m=audio 4000 RTP/AVP 0\r\n";
247 let offer = parse_audio_offer(sdp).expect("should find first audio after video");
248 assert_eq!(offer.port, 4000);
249 }
250
251 #[test]
252 fn connection_line_after_audio_section_starts_ignored() {
253 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\
255 m=audio 4000 RTP/AVP 0\r\n";
256 let offer = parse_audio_offer(sdp).expect("should parse correctly");
257 assert_eq!(offer.host, "192.0.2.1");
259 }
260
261 #[test]
262 fn session_level_connection_used_when_media_level_missing() {
263 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0\r\n";
265 let offer = parse_audio_offer(sdp).expect("should use session-level connection");
266 assert_eq!(offer.host, "192.0.2.1");
267 }
268
269 #[test]
270 fn payload_type_with_whitespace() {
271 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 0 8 101 \r\n";
273 let offer = parse_audio_offer(sdp).expect("should handle extra whitespace");
274 assert_eq!(offer.payload_types, vec![0, 8, 101]);
275 }
276
277 #[test]
278 fn port_number_edge_cases() {
279 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 1 RTP/AVP 0\r\n";
281 let offer = parse_audio_offer(sdp).expect("should accept port 1");
282 assert_eq!(offer.port, 1);
283
284 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 65535 RTP/AVP 0\r\n";
286 let offer = parse_audio_offer(sdp).expect("should accept max port");
287 assert_eq!(offer.port, 65535);
288 }
289
290 #[test]
291 fn invalid_port_number_rejected() {
292 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio abc RTP/AVP 0\r\n";
294 assert_eq!(parse_audio_offer(sdp), None);
295
296 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 70000 RTP/AVP 0\r\n";
298 assert_eq!(parse_audio_offer(sdp), None);
299 }
300
301 #[test]
302 fn rtpmap_with_extra_fields() {
303 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 101\r\n\
305 a=rtpmap:101 telephone-event/8000 extra stuff\r\n";
306 let offer = parse_audio_offer(sdp).expect("should parse despite extra fields");
307 assert_eq!(offer.telephone_event_pt, Some(101));
308 }
309
310 #[test]
311 fn answer_with_ipv6_address() {
312 let answer = audio_answer(7, "2001:db8::1", 4000, PT_PCMU, None);
314 assert!(
315 answer.contains("c=IN IP4 2001:db8::1\r\n"),
316 "IPv6 in output"
317 );
318 let parsed = parse_audio_offer(&answer).expect("should parse answer with IPv6");
320 assert_eq!(parsed.host, "2001:db8::1");
321 }
322
323 #[test]
324 fn answer_session_id_is_version() {
325 let answer = audio_answer(12345, "192.0.2.1", 4000, PT_PCMU, None);
327 assert!(
328 answer.contains("o=- 12345 12345 IN IP4"),
329 "session_id used for both fields"
330 );
331 }
332
333 #[test]
334 fn case_insensitive_telephone_event_match() {
335 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\nm=audio 4000 RTP/AVP 101\r\n\
337 a=rtpmap:101 TELEPHONE-EVENT/8000\r\n";
338 let offer = parse_audio_offer(sdp).expect("should parse");
339 assert_eq!(
340 offer.telephone_event_pt,
341 Some(101),
342 "case-insensitive match"
343 );
344 }
345
346 #[test]
347 fn empty_sdp_rejected() {
348 assert_eq!(parse_audio_offer(""), None);
349 }
350
351 #[test]
352 fn sdp_without_media_section_rejected() {
353 let sdp = "v=0\r\nc=IN IP4 192.0.2.1\r\ns=no media\r\n";
354 assert_eq!(parse_audio_offer(sdp), None);
355 }
356}