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. It is deliberately
8//! not a general SDP implementation — video sections, ICE, and crypto lines
9//! are ignored on the way in and never produced on the way out.
10
11use std::fmt::Write as _;
12
13use super::rtp::{PT_PCMA, PT_PCMU};
14
15/// The audio slice of a parsed SDP offer.
16#[derive(Debug, Clone, PartialEq)]
17pub struct AudioOffer {
18    /// Remote address for RTP, from the media-level or session-level `c=`.
19    pub host: String,
20    /// Remote RTP port from the `m=audio` line.
21    pub port: u16,
22    /// Payload types offered on the audio line, in preference order.
23    pub payload_types: Vec<u8>,
24    /// The dynamic payload type the offer maps to `telephone-event/8000`
25    /// (RFC 4733 DTMF), when present. Echoed in the answer so the caller
26    /// sends keypresses as events instead of in-band tones.
27    pub telephone_event_pt: Option<u8>,
28}
29
30impl AudioOffer {
31    /// The G.711 payload type to answer with: the offer's preference order,
32    /// filtered to what we implement. `None` when the offer has no G.711.
33    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
41/// Parse the audio portion of an SDP offer.
42///
43/// Returns `None` when there is no usable `m=audio` line or no connection
44/// address — an offer we cannot answer.
45pub 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            // c=IN IP4 203.0.113.5
56            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            // a=rtpmap:101 telephone-event/8000
64            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()?; // RTP/AVP
79                let payload_types = parts.filter_map(|p| p.parse().ok()).collect();
80                audio = Some((port, payload_types));
81            } else {
82                in_audio = false; // a later media section; stop attributing c= lines
83            }
84        }
85    }
86
87    let (port, payload_types) = audio?;
88    if port == 0 {
89        return None; // port 0 means the stream is refused
90    }
91    let host = media_host.or(session_host)?;
92    // Only meaningful if the audio line actually lists that payload type.
93    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
102/// Print an SDP answer committing to one G.711 codec, optionally accepting
103/// RFC 4733 telephone events (DTMF) on the payload type the offer proposed.
104///
105/// `session_id` doubles as the `o=` version; pass something unique per call
106/// (a timestamp, a counter). `host`/`port` are where we will receive RTP.
107pub 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    // SDP requires CRLF line endings on the wire.
140    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        // Offer prefers A-law; we honor its order.
164        assert_eq!(offer.g711_payload_type(), Some(PT_PCMA));
165        // RFC 4733 DTMF offered on payload type 101.
166        assert_eq!(offer.telephone_event_pt, Some(101));
167    }
168
169    #[test]
170    fn telephone_event_requires_the_media_line_to_list_it() {
171        // rtpmap alone is not enough — the m= line must carry the type.
172        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        // Port 0 refuses the stream.
187        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        // Video-only offer.
192        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        // Round-trip: our own answer parses as an offer.
217        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        // Round-trip: our own answer advertises the event type back.
229        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        // IPv6 addresses in c= line should be handled correctly
237        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        // Multiple media sections; should use first audio section only
245        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        // Connection lines after non-audio section are ignored
254        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        // Should use session-level connection (not the one after video)
258        assert_eq!(offer.host, "192.0.2.1");
259    }
260
261    #[test]
262    fn session_level_connection_used_when_media_level_missing() {
263        // Session-level connection should be used if media-level is missing
264        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        // Payload types separated by whitespace should parse correctly
272        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        // Port number 1 (minimum valid)
280        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        // Port number 65535 (maximum u16)
285        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        // Non-numeric port
293        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        // Port too large for u16
297        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        // rtpmap line with extra whitespace/fields should still parse
304        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        // Answer should handle IPv6 addresses
313        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        // Round-trip should work
319        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        // session_id becomes both session ID and version in o= line
326        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        // Telephone event matching should be case-insensitive
336        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}