gemini_adk_fluent_rs/telephony/
sdp.rs

1//! Minimal SDP (RFC 8866) offer/answer for G.711 audio calls.
2//!
3//! A SIP INVITE carries an SDP *offer* describing where the caller wants
4//! media sent and which codecs it can speak; the 200 OK carries the *answer*
5//! committing to one. This module implements exactly the slice a G.711 voice
6//! agent needs: parse the offer's audio media line and connection address,
7//! pick μ-law or A-law, and print a well-formed answer. An `RTP/SAVP` offer's
8//! SDES `a=crypto` lines are collected for the SRTP layer, and
9//! [`secure_audio_answer`] answers one. It is deliberately not a general SDP
10//! implementation: video sections and ICE are ignored on the way in and never
11//! produced on the way out.
12
13use std::fmt::Write as _;
14
15use super::rtp::{PT_PCMA, PT_PCMU};
16
17/// The audio slice of a parsed SDP offer.
18#[derive(Debug, Clone, PartialEq)]
19pub struct AudioOffer {
20    /// Remote address for RTP, from the media-level or session-level `c=`.
21    pub host: String,
22    /// Remote RTP port from the `m=audio` line.
23    pub port: u16,
24    /// Payload types offered on the audio line, in preference order.
25    pub payload_types: Vec<u8>,
26    /// The dynamic payload type the offer maps to `telephone-event/8000`
27    /// (RFC 4733 DTMF), when present. Echoed in the answer so the caller
28    /// sends keypresses as events instead of in-band tones.
29    pub telephone_event_pt: Option<u8>,
30    /// Whether the audio line's profile is secure (`RTP/SAVP`): the caller
31    /// sends SRTP and expects SRTP back.
32    pub secure: bool,
33    /// The values of the audio section's `a=crypto:` lines (SDES keys), in
34    /// preference order.
35    pub crypto: Vec<String>,
36}
37
38impl AudioOffer {
39    /// The G.711 payload type to answer with: the offer's preference order,
40    /// filtered to what we implement. `None` when the offer has no G.711.
41    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
49/// Parse the audio portion of an SDP offer.
50///
51/// Returns `None` when there is no usable `m=audio` line or no connection
52/// address — an offer we cannot answer.
53pub 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            // c=IN IP4 203.0.113.5
66            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            // a=rtpmap:101 telephone-event/8000
78            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                // RTP/AVP, or RTP/SAVP for SRTP.
93                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; // a later media section; stop attributing c= lines
98            }
99        }
100    }
101
102    let (port, payload_types) = audio?;
103    if port == 0 {
104        return None; // port 0 means the stream is refused
105    }
106    let host = media_host.or(session_host)?;
107    // Only meaningful if the audio line actually lists that payload type.
108    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
119/// Print an SDP answer committing to one G.711 codec, optionally accepting
120/// RFC 4733 telephone events (DTMF) on the payload type the offer proposed.
121///
122/// `session_id` doubles as the `o=` version; pass something unique per call
123/// (a timestamp, a counter). `host`/`port` are where we will receive RTP.
124pub 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
141/// [`audio_answer`] for an `RTP/SAVP` offer: the answer uses the secure
142/// profile and carries `crypto`, the value of our `a=crypto:` line (the tag
143/// and suite of the offer line we accept, and our own key).
144pub 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    // SDP requires CRLF line endings on the wire.
204    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        // Offer prefers A-law; we honor its order.
228        assert_eq!(offer.g711_payload_type(), Some(PT_PCMA));
229        // RFC 4733 DTMF offered on payload type 101.
230        assert_eq!(offer.telephone_event_pt, Some(101));
231    }
232
233    #[test]
234    fn telephone_event_requires_the_media_line_to_list_it() {
235        // rtpmap alone is not enough — the m= line must carry the type.
236        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        // Port 0 refuses the stream.
251        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        // Video-only offer.
256        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        // Round-trip: our own answer parses as an offer.
311        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        // Round-trip: our own answer advertises the event type back.
323        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        // IPv6 addresses in c= line should be handled correctly
331        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        // Multiple media sections; should use first audio section only
339        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        // Connection lines after non-audio section are ignored
348        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        // Should use session-level connection (not the one after video)
352        assert_eq!(offer.host, "192.0.2.1");
353    }
354
355    #[test]
356    fn session_level_connection_used_when_media_level_missing() {
357        // Session-level connection should be used if media-level is missing
358        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        // Payload types separated by whitespace should parse correctly
366        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        // Port number 1 (minimum valid)
374        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        // Port number 65535 (maximum u16)
379        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        // Non-numeric port
387        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        // Port too large for u16
391        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        // rtpmap line with extra whitespace/fields should still parse
398        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        // Answer should handle IPv6 addresses
407        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        // Round-trip should work
413        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        // session_id becomes both session ID and version in o= line
420        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        // Telephone event matching should be case-insensitive
430        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}