use std::net::Ipv4Addr;
use super::rtp::{PT_PCMA, PT_PCMU};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum G711Codec {
Pcmu,
Pcma,
}
impl G711Codec {
pub fn payload_type(self) -> u8 {
match self {
G711Codec::Pcmu => PT_PCMU,
G711Codec::Pcma => PT_PCMA,
}
}
pub fn rtpmap_name(self) -> &'static str {
match self {
G711Codec::Pcmu => "PCMU",
G711Codec::Pcma => "PCMA",
}
}
pub fn from_payload_type(pt: u8) -> Option<Self> {
match pt {
PT_PCMU => Some(G711Codec::Pcmu),
PT_PCMA => Some(G711Codec::Pcma),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdpMedia {
pub ip: Ipv4Addr,
pub port: u16,
pub codec: G711Codec,
}
pub fn build_offer(ip: Ipv4Addr, port: u16) -> String {
build_sdp(ip, port, &[G711Codec::Pcmu, G711Codec::Pcma])
}
pub fn build_answer(ip: Ipv4Addr, port: u16, codec: G711Codec) -> String {
build_sdp(ip, port, &[codec])
}
fn build_sdp(ip: Ipv4Addr, port: u16, codecs: &[G711Codec]) -> String {
let pts: Vec<String> = codecs
.iter()
.map(|c| c.payload_type().to_string())
.collect();
let mut s = String::new();
s.push_str("v=0\r\n");
s.push_str(&format!("o=- 0 0 IN IP4 {ip}\r\n"));
s.push_str("s=flowcat\r\n");
s.push_str(&format!("c=IN IP4 {ip}\r\n"));
s.push_str("t=0 0\r\n");
s.push_str(&format!("m=audio {port} RTP/AVP {}\r\n", pts.join(" ")));
for c in codecs {
s.push_str(&format!(
"a=rtpmap:{} {}/8000\r\n",
c.payload_type(),
c.rtpmap_name()
));
}
s.push_str("a=ptime:20\r\n");
s.push_str("a=sendrecv\r\n");
s
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SdpParseError {
NoAudioMedia,
NoConnection,
BadPort,
NoSupportedCodec,
}
impl std::fmt::Display for SdpParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SdpParseError::NoAudioMedia => write!(f, "SDP has no m=audio line"),
SdpParseError::NoConnection => write!(f, "SDP has no c=IN IP4 connection address"),
SdpParseError::BadPort => write!(f, "SDP m=audio line has no/invalid port"),
SdpParseError::NoSupportedCodec => write!(f, "SDP offers no PCMU/PCMA codec"),
}
}
}
pub fn parse(sdp: &str) -> Result<SdpMedia, SdpParseError> {
let mut session_ip: Option<Ipv4Addr> = None;
let mut media_ip: Option<Ipv4Addr> = None;
let mut port: Option<u16> = None;
let mut media_pts: Vec<u8> = Vec::new();
let mut in_audio = false;
let mut seen_audio = false;
for line in sdp.lines() {
let line = line.trim_end_matches('\r');
if let Some(rest) = line.strip_prefix("c=") {
if let Some(addr) = parse_connection_ipv4(rest) {
if in_audio {
media_ip = Some(addr);
} else {
session_ip = Some(addr);
}
}
} else if let Some(rest) = line.strip_prefix("m=") {
if let Some(audio) = rest.strip_prefix("audio ") {
if seen_audio {
in_audio = false;
continue;
}
in_audio = true;
seen_audio = true;
let mut parts = audio.split_whitespace();
port = parts.next().and_then(|p| p.parse::<u16>().ok());
let _transport = parts.next();
for pt in parts {
if let Ok(n) = pt.parse::<u8>() {
media_pts.push(n);
}
}
} else {
in_audio = false;
}
}
}
if !seen_audio {
return Err(SdpParseError::NoAudioMedia);
}
let ip = media_ip.or(session_ip).ok_or(SdpParseError::NoConnection)?;
let port = port.ok_or(SdpParseError::BadPort)?;
if port == 0 {
return Err(SdpParseError::BadPort);
}
let codec = media_pts
.iter()
.find_map(|&pt| G711Codec::from_payload_type(pt))
.ok_or(SdpParseError::NoSupportedCodec)?;
Ok(SdpMedia { ip, port, codec })
}
fn parse_connection_ipv4(rest: &str) -> Option<Ipv4Addr> {
let mut parts = rest.split_whitespace();
match (parts.next(), parts.next(), parts.next()) {
(Some("IN"), Some("IP4"), Some(addr)) => {
addr.split('/').next()?.parse().ok()
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(a: u8, b: u8, c: u8, d: u8) -> Ipv4Addr {
Ipv4Addr::new(a, b, c, d)
}
#[test]
fn answer_carries_public_ip_port_sendrecv_ptime_and_rtpmap() {
let pub_ip = ip(34, 124, 207, 59);
let sdp = build_answer(pub_ip, 41234, G711Codec::Pcmu);
assert!(
sdp.contains("c=IN IP4 34.124.207.59\r\n"),
"answer c= must be the public IP"
);
assert!(
sdp.contains("o=- 0 0 IN IP4 34.124.207.59\r\n"),
"o= origin must be the public IP"
);
assert!(
sdp.contains("m=audio 41234 RTP/AVP 0\r\n"),
"m= must carry the bound RTP port + PCMU only"
);
assert!(sdp.contains("a=rtpmap:0 PCMU/8000\r\n"));
assert!(sdp.contains("a=ptime:20\r\n"));
assert!(sdp.contains("a=sendrecv\r\n"));
assert!(
!sdp.contains("PCMA"),
"answer must commit to exactly one codec"
);
}
#[test]
fn parse_zadarma_style_offer_selects_pcmu() {
let sdp = "v=0\r\no=Zadarma 12345 1 IN IP4 185.45.152.42\r\ns=Zadarma\r\n\
c=IN IP4 185.45.152.42\r\nt=0 0\r\nm=audio 18722 RTP/AVP 0 101\r\n\
a=rtpmap:0 PCMU/8000\r\na=rtpmap:101 telephone-event/8000\r\n\
a=fmtp:101 0-16\r\na=ptime:20\r\na=sendrecv\r\n";
let m = parse(sdp).unwrap();
assert_eq!(m.codec, G711Codec::Pcmu);
assert_eq!(m.ip, ip(185, 45, 152, 42));
assert_eq!(m.port, 18722);
}
#[test]
fn parse_rejects_offer_without_g711() {
let sdp = "v=0\r\nc=IN IP4 9.9.9.9\r\nm=audio 5000 RTP/AVP 111 101\r\n\
a=rtpmap:111 opus/48000/2\r\na=rtpmap:101 telephone-event/8000\r\n";
assert_eq!(parse(sdp), Err(SdpParseError::NoSupportedCodec));
}
#[test]
fn parse_rejects_dtmf_only_offer() {
let sdp = "v=0\r\nc=IN IP4 1.2.3.4\r\nm=audio 5000 RTP/AVP 101\r\n\
a=rtpmap:101 telephone-event/8000\r\n";
assert_eq!(parse(sdp), Err(SdpParseError::NoSupportedCodec));
}
#[test]
fn parse_tolerates_lf_only_line_endings() {
let sdp = "v=0\nc=IN IP4 7.7.7.7\nm=audio 5000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\n";
let m = parse(sdp).unwrap();
assert_eq!(m.ip, ip(7, 7, 7, 7));
assert_eq!(m.port, 5000);
assert_eq!(m.codec, G711Codec::Pcmu);
}
#[test]
fn parse_ipv6_only_connection_yields_no_connection() {
let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nm=audio 5000 RTP/AVP 0\r\n";
assert_eq!(parse(sdp), Err(SdpParseError::NoConnection));
}
#[test]
fn parse_prefers_ipv4_media_connection_over_ipv6_session() {
let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nt=0 0\r\nm=audio 9000 RTP/AVP 0\r\n\
c=IN IP4 172.16.5.5\r\na=rtpmap:0 PCMU/8000\r\n";
let m = parse(sdp).unwrap();
assert_eq!(m.ip, ip(172, 16, 5, 5));
}
#[test]
fn parse_malformed_inputs_do_not_panic() {
assert!(parse("").is_err());
assert!(parse("this is not sdp at all\r\n\r\n").is_err());
assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio\r\n").is_err());
assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio abc RTP/AVP 0\r\n").is_err());
assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio 5000 RTP/AVP\r\n").is_err());
assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio 70000 RTP/AVP 0\r\n").is_err());
assert!(parse("m=\r\n").is_err());
assert!(parse("c=IN IP4\r\nm=audio 5000 RTP/AVP 0\r\n").is_err());
}
#[test]
fn parse_uses_first_audio_section_only() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 8000 RTP/AVP 0\r\n\
a=rtpmap:0 PCMU/8000\r\nm=audio 9000 RTP/AVP 8\r\n\
a=rtpmap:8 PCMA/8000\r\n";
let m = parse(sdp).unwrap();
assert_eq!(
m.port, 8000,
"second m=audio must not clobber the first stream's port"
);
assert_eq!(
m.codec,
G711Codec::Pcmu,
"PTs from the second m=audio must not leak in"
);
}
#[test]
fn offer_lists_both_codecs_with_ptime_and_sendrecv() {
let sdp = build_offer(ip(10, 0, 0, 5), 40000);
assert!(sdp.contains("m=audio 40000 RTP/AVP 0 8\r\n"));
assert!(sdp.contains("a=rtpmap:0 PCMU/8000\r\n"));
assert!(sdp.contains("a=rtpmap:8 PCMA/8000\r\n"));
assert!(sdp.contains("c=IN IP4 10.0.0.5\r\n"));
assert!(sdp.contains("a=ptime:20\r\n"));
assert!(sdp.contains("a=sendrecv\r\n"));
}
#[test]
fn answer_commits_to_one_codec() {
let sdp = build_answer(ip(192, 168, 1, 9), 5004, G711Codec::Pcma);
assert!(sdp.contains("m=audio 5004 RTP/AVP 8\r\n"));
assert!(sdp.contains("a=rtpmap:8 PCMA/8000\r\n"));
assert!(!sdp.contains("PCMU"));
}
#[test]
fn build_then_parse_offer_round_trips_pcmu() {
let sdp = build_offer(ip(203, 0, 113, 7), 12345);
let m = parse(&sdp).unwrap();
assert_eq!(m.ip, ip(203, 0, 113, 7));
assert_eq!(m.port, 12345);
assert_eq!(m.codec, G711Codec::Pcmu);
assert_eq!(m.codec.payload_type(), 0);
}
#[test]
fn build_then_parse_answer_round_trips_pcma() {
let sdp = build_answer(ip(8, 8, 4, 4), 6000, G711Codec::Pcma);
let m = parse(&sdp).unwrap();
assert_eq!(m.ip, ip(8, 8, 4, 4));
assert_eq!(m.port, 6000);
assert_eq!(m.codec, G711Codec::Pcma);
}
#[test]
fn parse_picks_first_listed_payload_type_pcma_when_peer_prefers_it() {
let sdp = "v=0\r\n\
o=- 0 0 IN IP4 1.2.3.4\r\n\
s=-\r\n\
c=IN IP4 1.2.3.4\r\n\
t=0 0\r\n\
m=audio 7000 RTP/AVP 8 0\r\n\
a=rtpmap:8 PCMA/8000\r\n\
a=rtpmap:0 PCMU/8000\r\n";
let m = parse(sdp).unwrap();
assert_eq!(m.codec, G711Codec::Pcma);
assert_eq!(m.port, 7000);
}
#[test]
fn parse_skips_unsupported_codecs_and_finds_g711() {
let sdp = "v=0\r\n\
c=IN IP4 9.9.9.9\r\n\
m=audio 5555 RTP/AVP 111 101 0\r\n\
a=rtpmap:111 opus/48000/2\r\n\
a=rtpmap:101 telephone-event/8000\r\n\
a=rtpmap:0 PCMU/8000\r\n";
let m = parse(sdp).unwrap();
assert_eq!(m.codec, G711Codec::Pcmu);
}
#[test]
fn media_level_connection_overrides_session_level() {
let sdp = "v=0\r\n\
o=- 0 0 IN IP4 10.0.0.1\r\n\
c=IN IP4 10.0.0.1\r\n\
t=0 0\r\n\
m=audio 9000 RTP/AVP 0\r\n\
c=IN IP4 172.16.5.5\r\n\
a=rtpmap:0 PCMU/8000\r\n";
let m = parse(sdp).unwrap();
assert_eq!(m.ip, ip(172, 16, 5, 5));
}
#[test]
fn parse_errors_on_missing_pieces() {
assert_eq!(
parse("v=0\r\nc=IN IP4 1.1.1.1\r\n"),
Err(SdpParseError::NoAudioMedia)
);
assert_eq!(
parse("v=0\r\nm=audio 5000 RTP/AVP 0\r\n"),
Err(SdpParseError::NoConnection)
);
assert_eq!(
parse("v=0\r\nc=IN IP4 1.1.1.1\r\nm=audio 0 RTP/AVP 0\r\n"),
Err(SdpParseError::BadPort)
);
assert_eq!(
parse("v=0\r\nc=IN IP4 1.1.1.1\r\nm=audio 5000 RTP/AVP 111\r\n"),
Err(SdpParseError::NoSupportedCodec)
);
}
}