use std::collections::HashMap;
use crate::sdp::{
codec::{NonCodecKind, NonCodecPayload, SdpCodec, SdpDirection, SdpMediaType},
codec_string::{CodecString, CodecStringEntry},
error::{CodecStringError, SdpCodecError, SdpWarning, UnmappedPayload},
options::CodecStringOptions,
static_payload,
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SdpCodecEntry {
Rtp(SdpCodec),
T38,
}
#[derive(Debug, Clone)]
pub struct SdpMediaSection {
media_type: SdpMediaType,
port: u16,
proto: String,
formats: String,
direction: SdpDirection,
entries: Vec<SdpCodecEntry>,
unmapped: Vec<UnmappedPayload>,
non_codec: Vec<NonCodecPayload>,
}
impl SdpMediaSection {
pub fn media_type(&self) -> &SdpMediaType {
&self.media_type
}
pub fn port(&self) -> u16 {
self.port
}
pub fn proto(&self) -> &str {
&self.proto
}
pub fn formats(&self) -> &str {
&self.formats
}
pub fn direction(&self) -> SdpDirection {
self.direction
}
pub fn entries(&self) -> &[SdpCodecEntry] {
&self.entries
}
pub fn unmapped(&self) -> &[UnmappedPayload] {
&self.unmapped
}
pub fn non_codec_payloads(&self) -> &[NonCodecPayload] {
&self.non_codec
}
pub fn is_negotiable(&self) -> bool {
let derives_entries = matches!(self.media_type, SdpMediaType::Audio | SdpMediaType::Video)
|| is_image(&self.media_type);
self.port != 0 && derives_entries
}
}
#[derive(Debug)]
pub struct SdpCodecs {
sections: Vec<SdpMediaSection>,
warnings: Vec<SdpWarning>,
}
impl SdpCodecs {
pub fn parse(sdp: &str) -> Result<Self, SdpCodecError> {
Self::parse_bytes(sdp.as_bytes())
}
pub fn parse_bytes(bytes: &[u8]) -> Result<Self, SdpCodecError> {
let session = sdp_types::Session::parse(bytes)
.map_err(|e| SdpCodecError::parse_failure("failed to parse SDP", e))?;
Self::from_session(&session)
}
fn from_session(session: &sdp_types::Session) -> Result<Self, SdpCodecError> {
let mut result = Self {
sections: Vec::new(),
warnings: Vec::new(),
};
let session_ptime = ptime_from_attrs(&session.attributes, "ptime", &mut result.warnings);
let session_maxptime =
ptime_from_attrs(&session.attributes, "maxptime", &mut result.warnings);
let session_direction =
direction_from_attrs(&session.attributes).unwrap_or(SdpDirection::SendRecv);
for media in &session.medias {
let media_type: SdpMediaType = match media
.media
.parse()
{
Ok(t) => t,
Err(e) => match e {},
};
let mut section = SdpMediaSection {
media_type: media_type.clone(),
port: media.port,
proto: media
.proto
.clone(),
formats: media
.fmt
.clone(),
direction: direction_from_attrs(&media.attributes).unwrap_or(session_direction),
entries: Vec::new(),
unmapped: Vec::new(),
non_codec: Vec::new(),
};
if is_image(&media_type) {
section
.entries
.push(SdpCodecEntry::T38);
} else if proto_has_rtp(&media.proto) {
match parse_media_section(
media,
media_type.clone(),
session_ptime,
session_maxptime,
section.direction,
) {
Ok(parsed) => {
section.entries = parsed.entries;
section.unmapped = parsed.unmapped;
section.non_codec = parsed.non_codec;
result
.warnings
.extend(parsed.warnings);
}
Err(e) => {
result
.warnings
.push(SdpWarning::malformed_media_section(
media_type.to_string(),
e.to_string(),
));
}
}
}
result
.sections
.push(section);
}
Ok(result)
}
pub fn sections(&self) -> &[SdpMediaSection] {
&self.sections
}
fn negotiable(&self) -> impl Iterator<Item = &SdpMediaSection> {
self.sections
.iter()
.filter(|s| s.is_negotiable())
}
pub fn entries(&self) -> impl Iterator<Item = &SdpCodecEntry> {
self.negotiable()
.flat_map(|s| {
s.entries
.iter()
})
}
pub fn into_entries(self) -> Vec<SdpCodecEntry> {
self.sections
.into_iter()
.filter(|s| s.is_negotiable())
.flat_map(|s| s.entries)
.collect()
}
pub fn len(&self) -> usize {
self.entries()
.count()
}
pub fn is_empty(&self) -> bool {
self.entries()
.next()
.is_none()
}
pub fn audio(&self) -> impl Iterator<Item = &SdpCodec> {
self.entries()
.filter_map(|e| match e {
SdpCodecEntry::Rtp(c) if c.media() == &SdpMediaType::Audio => Some(c),
_ => None,
})
}
pub fn video(&self) -> impl Iterator<Item = &SdpCodec> {
self.entries()
.filter_map(|e| match e {
SdpCodecEntry::Rtp(c) if c.media() == &SdpMediaType::Video => Some(c),
_ => None,
})
}
pub fn unmapped(&self) -> impl Iterator<Item = &UnmappedPayload> {
self.negotiable()
.flat_map(|s| {
s.unmapped
.iter()
})
}
pub fn warnings(&self) -> &[SdpWarning] {
&self.warnings
}
pub fn non_codec_payloads(&self) -> impl Iterator<Item = &NonCodecPayload> {
self.negotiable()
.flat_map(|s| {
s.non_codec
.iter()
})
}
pub fn audio_codec_string(
&self,
options: &CodecStringOptions,
mut warnings: Option<&mut Vec<SdpWarning>>,
) -> Result<CodecString, CodecStringError> {
let mut out = CodecString::new();
for entry in self.entries() {
match entry {
SdpCodecEntry::T38 => out.push(CodecStringEntry::new("t38")?),
SdpCodecEntry::Rtp(codec) if codec.media() == &SdpMediaType::Audio => {
if let Some(entry) =
codec_to_entry_lenient(codec, options, warnings.as_deref_mut())?
{
out.push(entry);
}
}
SdpCodecEntry::Rtp(_) => {}
}
}
Ok(out)
}
pub fn video_codec_string(
&self,
options: &CodecStringOptions,
mut warnings: Option<&mut Vec<SdpWarning>>,
) -> Result<CodecString, CodecStringError> {
let mut out = CodecString::new();
for codec in self.video() {
if let Some(entry) = codec_to_entry_lenient(codec, options, warnings.as_deref_mut())? {
out.push(entry);
}
}
Ok(out)
}
pub fn fmtp_for(&self, name: &str, clock_rate: u32) -> Option<&str> {
self.audio()
.filter(|c| {
c.name()
.eq_ignore_ascii_case(name)
&& c.clock_rate() == clock_rate
})
.find_map(|c| c.fmtp())
}
}
fn codec_to_entry_lenient(
codec: &SdpCodec,
options: &CodecStringOptions,
mut warnings: Option<&mut Vec<SdpWarning>>,
) -> Result<Option<CodecStringEntry>, CodecStringError> {
let mut entry = match CodecStringEntry::new(codec.name()) {
Ok(e) => e,
Err(e) => {
return match warnings.as_deref_mut() {
None => Err(e),
Some(acc) => {
acc.push(SdpWarning::codec_name_unrepresentable(
codec.name(),
e.to_string(),
));
Ok(None)
}
};
}
};
if options.emits_fmtp() {
if let Some(fmtp) = codec.fmtp() {
match entry
.clone()
.with_fmtp(fmtp)
{
Ok(with_fmtp) => entry = with_fmtp,
Err(e) => match warnings {
None => return Err(e),
Some(acc) => {
acc.push(SdpWarning::fmtp_unrepresentable(
codec.name(),
fmtp,
e.to_string(),
));
}
},
}
}
}
Ok(Some(options.apply_qualifiers(entry, codec)))
}
#[derive(Default)]
struct MediaSection {
entries: Vec<SdpCodecEntry>,
unmapped: Vec<UnmappedPayload>,
warnings: Vec<SdpWarning>,
non_codec: Vec<NonCodecPayload>,
}
fn is_image(media_type: &SdpMediaType) -> bool {
matches!(media_type, SdpMediaType::Other(s) if s.eq_ignore_ascii_case("image"))
}
fn proto_has_rtp(proto: &str) -> bool {
const RTP_PROTOS: [&str; 8] = [
"RTP",
"RTP/AVP",
"RTP/AVPF",
"RTP/SAVP",
"RTP/SAVPF",
"UDP/RTP/AVPF",
"UDP/TLS/RTP/SAVP",
"UDP/TLS/RTP/SAVPF",
];
RTP_PROTOS
.iter()
.any(|p| p.eq_ignore_ascii_case(proto))
}
fn non_codec_kind(canonical_name: &str) -> Option<NonCodecKind> {
if canonical_name.eq_ignore_ascii_case("telephone-event") {
Some(NonCodecKind::TelephoneEvent)
} else if canonical_name.eq_ignore_ascii_case("CN") {
Some(NonCodecKind::ComfortNoise)
} else {
None
}
}
fn parse_media_section(
media: &sdp_types::Media,
media_type: SdpMediaType,
session_ptime: Option<u32>,
session_maxptime: Option<u32>,
media_direction: SdpDirection,
) -> Result<MediaSection, SdpCodecError> {
let mut section = MediaSection::default();
let media_ptime =
ptime_from_attrs(&media.attributes, "ptime", &mut section.warnings).or(session_ptime);
let media_maxptime =
ptime_from_attrs(&media.attributes, "maxptime", &mut section.warnings).or(session_maxptime);
let mut rtpmap: HashMap<u8, (String, u32, Option<u8>)> = HashMap::new();
let mut fmtp_map: HashMap<u8, String> = HashMap::new();
for attr in &media.attributes {
match attr
.attribute
.as_str()
{
"rtpmap" => {
if let Some(val) = &attr.value {
let (pt, name, rate, channels) = parse_rtpmap(val)?;
rtpmap.insert(pt, (name, rate, channels));
}
}
"fmtp" => {
if let Some(val) = &attr.value {
let (pt, params) = parse_fmtp_pt(val)?;
fmtp_map.insert(pt, params);
}
}
_ => {}
}
}
for pt_str in media
.fmt
.split_whitespace()
{
let pt = pt_str
.parse::<u8>()
.map_err(|_| SdpCodecError::NonNumericPayloadType(pt_str.to_string()))?;
let (name, clock_rate, rtpmap_channels, has_rtpmap) =
if let Some((n, r, c)) = rtpmap.get(&pt) {
(n.as_str(), *r, *c, true)
} else if let Some(st) = static_payload::rfc3551_payload_type(pt) {
(st.encoding_name, st.clock_rate, st.channels, false)
} else {
section
.unmapped
.push(UnmappedPayload::new(pt, media_type.clone()));
continue;
};
let canonical = static_payload::canonical_iananame(name);
let fmtp = fmtp_map
.get(&pt)
.cloned();
if let Some(kind) = non_codec_kind(canonical) {
section
.non_codec
.push(NonCodecPayload {
kind,
payload_type: pt,
media_type: media_type.clone(),
clock_rate,
fmtp,
has_rtpmap,
});
continue;
}
let channels = match (&media_type, rtpmap_channels) {
(SdpMediaType::Audio, None) => Some(1),
_ => rtpmap_channels,
};
let mut ptime = media_ptime;
if ptime.is_none() {
ptime = Some(default_ptime_ms(canonical));
}
let mut bitrate = static_payload::known_bitrate(pt);
if fmtp.is_none() {
if canonical.eq_ignore_ascii_case("ilbc") {
ptime = Some(30);
bitrate = Some(13330);
} else if canonical.eq_ignore_ascii_case("isac") {
ptime = Some(30);
bitrate = Some(32000);
}
}
if let Some(ref fmtp_str) = fmtp {
if canonical.eq_ignore_ascii_case("opus") {
if let Some(p) = fmtp_param(fmtp_str, "ptime") {
if let Some(v) = parse_ptime_value(p, &mut section.warnings, "fmtp ptime") {
ptime = Some(v);
}
}
} else if canonical.eq_ignore_ascii_case("ilbc") {
ptime = Some(match fmtp_param(fmtp_str, "mode") {
Some(m) => {
parse_ptime_value(m, &mut section.warnings, "fmtp mode").unwrap_or(30)
}
None => 30,
});
} else if canonical.eq_ignore_ascii_case("g7221") {
if let Some(br_str) = fmtp_param(fmtp_str, "bitrate") {
match br_str.parse::<u32>() {
Ok(br) => bitrate = Some(br),
Err(_) => {
section
.warnings
.push(SdpWarning::unparseable_numeric_attribute(
"g7221 fmtp bitrate",
br_str,
));
}
}
}
}
}
section
.entries
.push(SdpCodecEntry::Rtp(SdpCodec::new(
media_type.clone(),
pt,
canonical,
clock_rate,
channels,
fmtp,
ptime,
media_maxptime,
bitrate,
media_direction,
has_rtpmap,
)));
}
Ok(section)
}
struct AttrCursor<'a> {
rest: &'a str,
}
impl<'a> AttrCursor<'a> {
fn new(value: &'a str) -> Self {
Self { rest: value }
}
fn skip_ws(&mut self) {
self.rest = self
.rest
.trim_start_matches([' ', '\t']);
}
fn number<T: std::str::FromStr>(&mut self) -> Option<T> {
self.skip_ws();
let end = self
.rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(
self.rest
.len(),
);
if end == 0 {
return None;
}
let (digits, rest) = self
.rest
.split_at(end);
self.rest = rest;
self.skip_ws();
digits
.parse()
.ok()
}
fn field(&mut self, stop: &[char]) -> &'a str {
self.skip_ws();
let end = self
.rest
.find(|c: char| c == ' ' || c == '\t' || stop.contains(&c))
.unwrap_or(
self.rest
.len(),
);
let (field, rest) = self
.rest
.split_at(end);
self.rest = rest;
field
}
fn eat(&mut self, ch: char) -> bool {
match self
.rest
.strip_prefix(ch)
{
Some(rest) => {
self.rest = rest;
true
}
None => false,
}
}
fn remaining(&self) -> &'a str {
self.rest
}
}
fn parse_rtpmap(value: &str) -> Result<(u8, String, u32, Option<u8>), SdpCodecError> {
let mut cursor = AttrCursor::new(value);
let pt = cursor
.number()
.ok_or_else(|| SdpCodecError::MalformedRtpmap(value.to_string()))?;
let name = cursor.field(&['/']);
if name.is_empty() {
return Err(SdpCodecError::MalformedRtpmap(value.to_string()));
}
let name = name.to_string();
if !cursor.eat('/') {
return Err(SdpCodecError::MalformedRtpmap(value.to_string()));
}
let clock_rate = cursor
.number()
.ok_or_else(|| SdpCodecError::MalformedRtpmap(value.to_string()))?;
let channels = if cursor.eat('/') {
cursor.skip_ws();
if cursor
.remaining()
.is_empty()
{
None
} else {
Some(
cursor
.number()
.ok_or_else(|| SdpCodecError::MalformedRtpmap(value.to_string()))?,
)
}
} else {
None
};
if !cursor
.remaining()
.is_empty()
{
return Err(SdpCodecError::MalformedRtpmap(value.to_string()));
}
Ok((pt, name, clock_rate, channels))
}
fn parse_fmtp_pt(value: &str) -> Result<(u8, String), SdpCodecError> {
let mut cursor = AttrCursor::new(value);
let pt = cursor
.number()
.ok_or_else(|| SdpCodecError::MalformedFmtp(value.to_string()))?;
Ok((
pt,
cursor
.remaining()
.to_string(),
))
}
fn ptime_from_attrs(
attrs: &[sdp_types::Attribute],
name: &str,
warnings: &mut Vec<SdpWarning>,
) -> Option<u32> {
attrs
.iter()
.find(|a| a.attribute == name)
.and_then(|a| {
a.value
.as_deref()
})
.and_then(|v| parse_ptime_value(v, warnings, name))
}
fn parse_ptime_value(raw: &str, warnings: &mut Vec<SdpWarning>, attr: &str) -> Option<u32> {
let int_str = if let Some((prefix, _)) = raw.split_once('.') {
prefix
} else {
raw
};
match int_str.parse::<u32>() {
Ok(0) | Err(_) => {
warnings.push(SdpWarning::unparseable_numeric_attribute(attr, raw));
None
}
Ok(n) => Some(n),
}
}
fn direction_from_attrs(attrs: &[sdp_types::Attribute]) -> Option<SdpDirection> {
attrs
.iter()
.find_map(|a| {
if a.value
.is_none()
{
a.attribute
.parse::<SdpDirection>()
.ok()
} else {
None
}
})
}
fn default_ptime_ms(canonical_name: &str) -> u32 {
super::static_payload::default_ptime(canonical_name)
}
fn fmtp_param<'a>(fmtp: &'a str, key: &str) -> Option<&'a str> {
fmtp.split(';')
.find_map(|part| {
let part = part.trim();
let (k, v) = part.split_once('=')?;
if k.trim()
.eq_ignore_ascii_case(key)
{
Some(v.trim())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sdp_header() -> String {
"v=0\r\no=- 0 0 IN IP4 192.0.2.1\r\ns=-\r\nt=0 0\r\n".to_string()
}
fn rtp_codec<'a>(entries: impl IntoIterator<Item = &'a SdpCodecEntry>) -> Vec<&'a SdpCodec> {
entries
.into_iter()
.filter_map(|e| {
if let SdpCodecEntry::Rtp(c) = e {
Some(c)
} else {
None
}
})
.collect()
}
fn codec_named<'a>(entries: &[&'a SdpCodec], name: &str) -> Option<&'a SdpCodec> {
entries
.iter()
.find(|c| {
c.name()
.eq_ignore_ascii_case(name)
})
.copied()
}
#[test]
fn static_only_pcmu_pcma_g729_no_rtpmap() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0 8 18\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
let entries = codecs.entries();
let rtp = rtp_codec(entries);
assert_eq!(rtp.len(), 3);
assert!(codec_named(&rtp, "PCMU").is_some());
assert!(codec_named(&rtp, "PCMA").is_some());
assert!(codec_named(&rtp, "G729").is_some());
for c in &rtp {
assert!(!c.has_rtpmap(), "static types must have has_rtpmap=false");
}
}
#[test]
fn g722_rtpmap_clock_rate_is_8000() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 9\r\na=rtpmap:9 G722/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let g722 = codec_named(&rtp, "G722").expect("G722 must be present");
assert_eq!(g722.clock_rate(), 8000);
assert!(g722.has_rtpmap());
}
#[test]
fn opus_stereo_channels_2() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let opus = codec_named(&rtp, "opus").expect("opus must be present");
assert_eq!(opus.channels(), Some(2));
assert_eq!(opus.clock_rate(), 48000);
}
#[test]
fn amrwb_no_channel_field_equals_explicit_one() {
let sdp_no_ch = format!(
"{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 AMR-WB/16000\r\n",
sdp_header()
);
let sdp_ch1 = format!(
"{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 AMR-WB/16000/1\r\n",
sdp_header()
);
let c_no_ch = SdpCodecs::parse(&sdp_no_ch).unwrap();
let c_ch1 = SdpCodecs::parse(&sdp_ch1).unwrap();
let rtp_no_ch = rtp_codec(c_no_ch.entries());
let rtp_ch1 = rtp_codec(c_ch1.entries());
let amrwb_no_ch = codec_named(&rtp_no_ch, "AMR-WB").expect("AMR-WB must be present");
let amrwb_ch1 = codec_named(&rtp_ch1, "AMR-WB").expect("AMR-WB must be present");
assert_eq!(
amrwb_no_ch.channels(),
amrwb_ch1.channels(),
"missing /1 and explicit /1 must normalize to the same channel count"
);
assert_eq!(amrwb_ch1.channels(), Some(1));
}
#[test]
fn ptime_opus_fmtp_overrides_session_ptime() {
let sdp = format!(
"{}a=ptime:20\r\nm=audio 5004 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 ptime=40\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let opus = codec_named(&rtp, "opus").expect("opus must be present");
assert_eq!(opus.ptime(), Some(40), "fmtp ptime= must override a=ptime");
}
#[test]
fn ptime_ilbc_no_fmtp_overrides_explicit_ptime() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 97\r\na=rtpmap:97 iLBC/8000\r\na=ptime:20\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let ilbc = codec_named(&rtp, "iLBC").expect("iLBC must be present");
assert_eq!(
ilbc.ptime(),
Some(30),
"iLBC without fmtp must always yield ptime=30"
);
}
#[test]
fn ptime_ilbc_fmtp_mode20_yields_20_no_bitrate() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 97\r\na=rtpmap:97 iLBC/8000\r\na=fmtp:97 mode=20\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let ilbc = codec_named(&rtp, "iLBC").expect("iLBC must be present");
assert_eq!(ilbc.ptime(), Some(20));
assert_eq!(
ilbc.bitrate(),
None,
"fmtp present — bitrate not set by static table"
);
}
#[test]
fn media_ptime_overrides_session_ptime() {
let sdp = format!(
"{}a=ptime:10\r\nm=audio 5004 RTP/AVP 0\r\na=ptime:30\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let pcmu = codec_named(&rtp, "PCMU").expect("PCMU must be present");
assert_eq!(
pcmu.ptime(),
Some(30),
"media-level a=ptime must override session-level"
);
}
#[test]
fn session_ptime_applies_when_media_has_none() {
let sdp = format!("{}a=ptime:10\r\nm=audio 5004 RTP/AVP 8\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let pcma = codec_named(&rtp, "PCMA").expect("PCMA must be present");
assert_eq!(
pcma.ptime(),
Some(10),
"session-level a=ptime must apply when media has none"
);
}
#[test]
fn ptime_fractional_value_is_truncated() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0\r\na=ptime:20.0\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs
.warnings()
.is_empty());
let rtp = rtp_codec(codecs.entries());
let pcmu = codec_named(&rtp, "PCMU").expect("PCMU must be present");
assert_eq!(pcmu.ptime(), Some(20));
}
#[test]
fn ptime_junk_is_unset_with_warning() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0\r\na=ptime:abc\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.warnings()
.len(),
1,
"junk ptime must produce exactly one warning"
);
let rtp = rtp_codec(codecs.entries());
let pcmu = codec_named(&rtp, "PCMU").expect("PCMU must be present");
assert_eq!(pcmu.ptime(), Some(20));
}
#[test]
fn ptime_zero_is_unset_with_warning() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0\r\na=ptime:0\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.warnings()
.len(),
1,
"ptime=0 must produce exactly one warning"
);
let rtp = rtp_codec(codecs.entries());
let pcmu = codec_named(&rtp, "PCMU").expect("PCMU must be present");
assert_eq!(pcmu.ptime(), Some(20));
}
#[test]
fn port_zero_section_contributes_nothing_but_is_retained_whole() {
let sdp = format!(
concat!(
"{}",
"m=audio 0 RTP/AVP 104 96\r\n",
"a=rtpmap:104 AMR-WB/16000\r\n",
"a=fmtp:104 mode-set=1; max-red=0\r\n",
"a=rtpmap:96 telephone-event/16000\r\n",
"a=fmtp:96 0-15\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs.is_empty(), "port 0 contributes no codec entries");
assert!(codecs
.audio_codec_string(&CodecStringOptions::audio(), None)
.unwrap()
.is_empty());
let sections = codecs.sections();
assert_eq!(
sections.len(),
1,
"every m= line yields exactly one section"
);
let held = §ions[0];
assert_eq!(held.port(), 0);
assert!(!held.is_negotiable());
assert_eq!(held.media_type(), &SdpMediaType::Audio);
let rtp = rtp_codec(held.entries());
let amr = codec_named(&rtp, "AMR-WB").expect("the held codec must survive by name");
assert_eq!(amr.clock_rate(), 16000);
assert_eq!(amr.fmtp(), Some("mode-set=1; max-red=0"));
let te = held.non_codec_payloads();
assert_eq!(te.len(), 1);
assert_eq!(
te[0]
.fmtp
.as_deref(),
Some("0-15")
);
}
#[test]
fn a_retained_sections_payloads_stay_out_of_the_top_level_views() {
let sdp = format!(
concat!(
"{}",
"m=audio 0 RTP/AVP 0 99 101\r\n",
"a=rtpmap:101 telephone-event/8000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.non_codec_payloads()
.count(),
0
);
assert_eq!(
codecs
.unmapped()
.count(),
0
);
let held = &codecs.sections()[0];
assert_eq!(
held.non_codec_payloads()
.len(),
1
);
assert_eq!(
held.unmapped()
.len(),
1,
"PT 99 has no rtpmap and no static entry, on the section it appeared in"
);
}
#[test]
fn fmtp_for_ignores_a_held_sections_parameters() {
let sdp = format!(
concat!(
"{}",
"m=audio 0 RTP/AVP 104\r\n",
"a=rtpmap:104 AMR-WB/16000\r\n",
"a=fmtp:104 octet-align=1\r\n",
"m=audio 5004 RTP/AVP 105\r\n",
"a=rtpmap:105 AMR-WB/16000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(codecs.fmtp_for("AMR-WB", 16000), None);
}
#[test]
fn port_zero_image_section_yields_no_t38_entry() {
let sdp = format!("{}m=image 0 udptl t38\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs.is_empty());
assert!(codecs
.audio_codec_string(&CodecStringOptions::audio(), None)
.unwrap()
.is_empty());
let declined = &codecs.sections()[0];
assert!(!declined.is_negotiable());
assert!(matches!(declined.entries()[0], SdpCodecEntry::T38));
}
#[test]
fn non_rtp_proto_reads_no_payload_types_and_raises_no_warning() {
let sdp = format!(
"{}m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs
.warnings()
.is_empty());
let section = &codecs.sections()[0];
assert!(section
.entries()
.is_empty());
assert_eq!(section.proto(), "UDP/DTLS/SCTP");
assert_eq!(section.formats(), "webrtc-datachannel");
}
#[test]
fn non_rtp_proto_on_audio_gets_the_same_treatment() {
let sdp = format!("{}m=audio 5004 udptl t38\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs
.warnings()
.is_empty());
assert!(codecs.is_empty());
assert_eq!(codecs.sections()[0].formats(), "t38");
}
#[test]
fn a_proto_merely_containing_rtp_is_not_an_rtp_transport() {
let sdp = format!("{}m=audio 5004 TCP/RTP/AVP 0\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs.is_empty());
assert!(codecs.sections()[0]
.entries()
.is_empty());
}
#[test]
fn text_section_codecs_are_retained_but_never_negotiable() {
let sdp = format!(
"{}m=text 5000 RTP/AVP 98\r\na=rtpmap:98 t140/1000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs.is_empty());
assert_eq!(
codecs
.audio()
.count(),
0
);
assert_eq!(
codecs
.video()
.count(),
0
);
let section = &codecs.sections()[0];
assert!(!section.is_negotiable());
let rtp = rtp_codec(section.entries());
assert_eq!(
codec_named(&rtp, "t140")
.expect("the text codec must survive")
.clock_rate(),
1000
);
}
#[test]
fn sections_keep_m_line_order_across_negotiable_and_not() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0\r\n",
"m=video 0 RTP/AVP 99\r\n",
"a=rtpmap:99 H264/90000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let sections = codecs.sections();
assert_eq!(sections[0].media_type(), &SdpMediaType::Audio);
assert!(sections[0].is_negotiable());
assert_eq!(sections[1].media_type(), &SdpMediaType::Video);
assert!(!sections[1].is_negotiable());
let rtp = rtp_codec(codecs.entries());
assert_eq!(rtp.len(), 1, "the declined video must not reach entries()");
assert!(codec_named(&rtp, "PCMU").is_some());
}
#[test]
fn a_malformed_section_is_retained_beside_its_warning() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 0\r\na=rtpmap:x PCMU/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.warnings()
.len(),
1
);
let section = &codecs.sections()[0];
assert!(section
.entries()
.is_empty());
assert_eq!(section.port(), 5004);
}
#[test]
fn a_non_negotiable_section_still_reports_its_parse_warnings() {
let sdp = format!("{}m=audio 0 RTP/AVP 0\r\na=ptime:abc\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.warnings()
.len(),
1
);
}
#[test]
fn ipv6_connection_parses_without_error() {
let sdp = format!(
"{}c=IN IP6 2001:db8::1\r\nm=audio 5004 RTP/AVP 0\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(codecs.len(), 1);
}
#[test]
fn image_m_line_yields_t38_entry() {
let sdp = format!("{}m=image 5008 udptl t38\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(codecs.len(), 1);
assert!(matches!(
codecs
.entries()
.next(),
Some(SdpCodecEntry::T38)
));
}
#[test]
fn image_m_line_with_other_proto_still_yields_t38() {
let sdp = format!(
"{}m=image 5008 RTP/AVP 98\r\na=rtpmap:98 t38/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(codecs.len(), 1);
assert!(matches!(
codecs
.entries()
.next(),
Some(SdpCodecEntry::T38)
));
}
#[test]
fn t38_before_image_precedes_audio_in_codec_string() {
let sdp = format!(
concat!(
"{}",
"m=image 5008 udptl t38\r\n",
"m=audio 5004 RTP/AVP 0\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.entries()[0].name(), "t38");
assert_eq!(cs.entries()[1].name(), "PCMU");
}
#[test]
fn application_m_line_does_not_fail() {
let sdp = format!(
"{}m=application 9 UDP/BFCP *\r\nm=audio 5004 RTP/AVP 0\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(codecs.len(), 1);
let rtp = rtp_codec(codecs.entries());
assert!(codec_named(&rtp, "PCMU").is_some());
}
#[test]
fn telephone_event_retains_payload_type_rate_and_fmtp() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0 101 102\r\n",
"a=rtpmap:101 telephone-event/8000\r\n",
"a=fmtp:101 0-16\r\n",
"a=rtpmap:102 telephone-event/16000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let te: Vec<_> = codecs
.non_codec_payloads()
.collect();
assert_eq!(
te.len(),
2,
"both telephone-event payloads must be retained"
);
assert_eq!(te[0].kind, NonCodecKind::TelephoneEvent);
assert_eq!(te[0].payload_type, 101);
assert_eq!(te[0].clock_rate, 8000);
assert_eq!(
te[0]
.fmtp
.as_deref(),
Some("0-16"),
"the offered digit range is what an operator reads on a DTMF fault"
);
assert!(te[0].has_rtpmap);
assert_eq!(te[0].media_type, SdpMediaType::Audio);
assert_eq!(te[1].payload_type, 102);
assert_eq!(te[1].clock_rate, 16000);
assert!(te[1]
.fmtp
.is_none());
let rtp = rtp_codec(codecs.entries());
assert!(codec_named(&rtp, "telephone-event").is_none());
assert_eq!(
codecs
.unmapped()
.count(),
0
);
}
#[test]
fn comfort_noise_from_static_table_has_no_rtpmap() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0 13\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cn: Vec<_> = codecs
.non_codec_payloads()
.collect();
assert_eq!(cn.len(), 1);
assert_eq!(cn[0].kind, NonCodecKind::ComfortNoise);
assert_eq!(cn[0].payload_type, 13);
assert_eq!(cn[0].clock_rate, 8000);
assert!(cn[0]
.fmtp
.is_none());
assert!(
!cn[0].has_rtpmap,
"PT 13 resolved from the RFC 3551 table, not declared by the peer"
);
let rtp = rtp_codec(codecs.entries());
assert!(codec_named(&rtp, "CN").is_none());
assert_eq!(
codecs
.unmapped()
.count(),
0
);
}
#[test]
fn comfort_noise_with_explicit_rtpmap_is_distinguishable() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0 13\r\n",
"a=rtpmap:13 CN/8000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cn: Vec<_> = codecs
.non_codec_payloads()
.collect();
assert_eq!(cn.len(), 1);
assert!(cn[0].has_rtpmap);
}
#[test]
fn non_codec_payloads_are_an_inventory_not_a_set() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0 101\r\n",
"a=rtpmap:101 telephone-event/8000\r\n",
"m=audio 5006 RTP/AVP 8 96\r\n",
"a=rtpmap:96 telephone-event/8000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let te: Vec<_> = codecs
.non_codec_payloads()
.collect();
assert_eq!(te.len(), 2);
assert_eq!(te[0].payload_type, 101);
assert_eq!(te[1].payload_type, 96);
}
#[test]
fn non_codec_payloads_never_reach_the_codec_string() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0 13 101\r\n",
"a=rtpmap:101 telephone-event/8000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.non_codec_payloads()
.count(),
2
);
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.len(), 1, "only PCMU may reach the codec string");
let rendered = cs.to_string();
assert!(!rendered.contains("telephone-event"));
assert!(!rendered.contains("CN"));
}
#[test]
fn dynamic_pt_without_rtpmap_is_unmapped_not_error() {
let sdp = format!("{}m=audio 5004 RTP/AVP 0 99\r\n", sdp_header());
let codecs = SdpCodecs::parse(&sdp).unwrap();
let unmapped: Vec<_> = codecs
.unmapped()
.collect();
assert_eq!(unmapped.len(), 1);
assert_eq!(unmapped[0].payload_type, 99);
let rtp = rtp_codec(codecs.entries());
assert_eq!(rtp.len(), 1);
assert!(codec_named(&rtp, "PCMU").is_some());
}
#[test]
fn malformed_rtpmap_non_numeric_pt_skips_section_with_warning() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 0\r\na=rtpmap:x PCMU/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.entries()
.count(),
0,
"the whole malformed section must be skipped, not just the bad attribute"
);
assert_eq!(
codecs
.warnings()
.len(),
1
);
assert!(matches!(
codecs.warnings()[0],
SdpWarning::MalformedMediaSection { .. }
));
}
#[test]
fn rtpmap_double_space_before_name_is_parsed() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 111\r\na=rtpmap:111 opus/48000/2\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let opus = codec_named(&rtp, "opus").expect("opus must be present, not \" opus\"");
assert_eq!(opus.clock_rate(), 48000);
assert_eq!(opus.channels(), Some(2));
}
#[test]
fn rtpmap_tab_separator_is_parsed() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 0\r\na=rtpmap:0\tPCMU/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
let pcmu = codec_named(&rtp, "PCMU").expect("PCMU must be present");
assert_eq!(pcmu.clock_rate(), 8000);
assert_eq!(
pcmu.channels(),
Some(1),
"no channel field normalizes to mono"
);
}
#[test]
fn rtpmap_interior_whitespace_in_name_is_malformed() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 0\r\na=rtpmap:0 foo bar/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.entries()
.count(),
0,
"malformed rtpmap must still drop the whole section"
);
assert!(matches!(
codecs.warnings()[0],
SdpWarning::MalformedMediaSection { .. }
));
}
#[test]
fn malformed_fmtp_non_numeric_pt_skips_section_with_warning() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 0\r\na=fmtp:x minptime=10\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.entries()
.count(),
0
);
assert_eq!(
codecs
.warnings()
.len(),
1
);
assert!(matches!(
codecs.warnings()[0],
SdpWarning::MalformedMediaSection { .. }
));
}
#[test]
fn g7221_malformed_bitrate_records_warning() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 100\r\n",
"a=rtpmap:100 G7221/16000\r\n",
"a=fmtp:100 bitrate=abc\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs
.warnings()
.len(),
1,
"malformed g7221 bitrate must produce exactly one warning"
);
let rtp = rtp_codec(codecs.entries());
let g7221 = codec_named(&rtp, "G7221").expect("G7221 must be present");
assert_eq!(
g7221.bitrate(),
None,
"bitrate must be None after parse error"
);
}
#[test]
fn g7221_valid_bitrate_is_set() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 100\r\n",
"a=rtpmap:100 G7221/16000\r\n",
"a=fmtp:100 bitrate=24000\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert!(codecs
.warnings()
.is_empty());
let rtp = rtp_codec(codecs.entries());
let g7221 = codec_named(&rtp, "G7221").expect("G7221 must be present");
assert_eq!(g7221.bitrate(), Some(24000));
}
#[test]
fn amr_oa_be_pair_collapses_after_dedup() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 96 97\r\n",
"a=rtpmap:96 AMR/8000\r\n",
"a=fmtp:96 octet-align=1\r\n",
"a=rtpmap:97 AMR/8000\r\n",
"a=fmtp:97 octet-align=0\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.len(), 2, "both AMR entries must be emitted");
assert_eq!(
cs.entries()[0],
cs.entries()[1],
"entries must be identical under default options (no fmtp)"
);
let mut deduped = cs.clone();
deduped.dedup();
assert_eq!(
deduped.len(),
1,
"duplicate AMR entries must collapse under dedup()"
);
}
#[test]
fn fmtp_for_falls_through_to_later_codec_with_fmtp() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 97 96\r\n",
"a=rtpmap:97 AMR/8000\r\n",
"a=rtpmap:96 AMR/8000\r\n",
"a=fmtp:96 octet-align=1\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs.fmtp_for("AMR", 8000),
Some("octet-align=1"),
"fmtp_for must fall through a fmtp-less match to a later one that has fmtp"
);
}
#[test]
fn malformed_media_section_is_skipped_not_fatal() {
let sdp = format!(
concat!(
"{}",
"m=audio 9 RTP/AVP *\r\n",
"m=audio 5004 RTP/AVP 0 8\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let rtp = rtp_codec(codecs.entries());
assert!(codec_named(&rtp, "PCMU").is_some());
assert!(codec_named(&rtp, "PCMA").is_some());
assert_eq!(
codecs
.warnings()
.len(),
1,
"the malformed section must record exactly one warning"
);
assert!(matches!(
codecs.warnings()[0],
SdpWarning::MalformedMediaSection { .. }
));
}
#[test]
fn amrwb_absent_and_explicit_channel_collapse_after_dedup() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 100 101\r\n",
"a=rtpmap:100 AMR-WB/16000\r\n",
"a=rtpmap:101 AMR-WB/16000/1\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.len(), 2);
let mut deduped = cs.clone();
deduped.dedup();
assert_eq!(
deduped.len(),
1,
"absent and explicit /1 channel count must normalize identically"
);
}
#[test]
fn evs_dotted_fmtp_unreachable_under_default_options() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 110\r\n",
"a=rtpmap:110 EVS/16000\r\n",
"a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.len(), 1);
assert!(
cs.entries()[0]
.fmtp()
.is_none(),
"fmtp emission is off by default; the dotted fmtp path is unreachable"
);
}
#[test]
fn evs_dotted_fmtp_lenient_clears_and_warns() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 110\r\n",
"a=rtpmap:110 EVS/16000\r\n",
"a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let opts = CodecStringOptions::default().with_fmtp(true);
let mut warnings = Vec::new();
let cs = codecs
.audio_codec_string(&opts, Some(&mut warnings))
.unwrap();
assert_eq!(cs.len(), 1, "the codec must still be emitted");
assert!(
cs.entries()[0]
.fmtp()
.is_none(),
"unrepresentable fmtp must be cleared, not left dangling"
);
assert_eq!(warnings.len(), 1);
assert!(matches!(
warnings[0],
SdpWarning::FmtpUnrepresentable { .. }
));
}
#[test]
fn evs_dotted_fmtp_strict_is_err() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 110\r\n",
"a=rtpmap:110 EVS/16000\r\n",
"a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let opts = CodecStringOptions::default().with_fmtp(true);
let result = codecs.audio_codec_string(&opts, None);
assert!(
result.is_err(),
"strict mode must fail on an unrepresentable fmtp"
);
}
#[test]
fn g722_rate_is_8000_never_16000() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 9\r\na=rtpmap:9 G722/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert_eq!(cs.len(), 1);
assert_eq!(cs.entries()[0].name(), "G722");
assert_eq!(cs.entries()[0].rate(), Some(8000));
assert_eq!(cs.entries()[0].ptime(), Some(20));
}
#[test]
fn video_codec_string_carries_no_rate_qualifier() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 0\r\n",
"m=video 5006 RTP/AVP 96\r\n",
"a=rtpmap:96 H264/90000\r\n",
"m=image 5008 udptl t38\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let video_cs = codecs
.video_codec_string(&CodecStringOptions::video(), None)
.unwrap();
assert_eq!(video_cs.len(), 1);
assert_eq!(video_cs.entries()[0].name(), "H264");
assert!(
video_cs.entries()[0]
.rate()
.is_none(),
"CodecStringOptions::video() must not emit a rate qualifier"
);
let audio_cs = codecs
.audio_codec_string(&CodecStringOptions::default(), None)
.unwrap();
assert!(
audio_cs.contains_name("t38"),
"an offered m=image section must append a literal t38 entry to the audio string"
);
}
#[test]
fn codec_name_with_delimiter_is_skipped_lenient_and_erred_strict() {
let sdp = format!(
"{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 bad,name/8000\r\n",
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
let mut warnings = Vec::new();
let cs = codecs
.audio_codec_string(&CodecStringOptions::default(), Some(&mut warnings))
.unwrap();
assert!(cs.is_empty(), "the unrepresentable codec must be skipped");
assert_eq!(warnings.len(), 1);
assert!(matches!(
warnings[0],
SdpWarning::CodecNameUnrepresentable { .. }
));
let result = codecs.audio_codec_string(&CodecStringOptions::default(), None);
assert!(result.is_err(), "strict mode must fail");
}
#[test]
fn fmtp_for_looks_up_by_name_and_rate_case_insensitively() {
let sdp = format!(
concat!(
"{}",
"m=audio 5004 RTP/AVP 100\r\n",
"a=rtpmap:100 AMR-WB/16000\r\n",
"a=fmtp:100 octet-align=1\r\n"
),
sdp_header()
);
let codecs = SdpCodecs::parse(&sdp).unwrap();
assert_eq!(
codecs.fmtp_for("amr-wb", 16000),
Some("octet-align=1"),
"lookup must be case-insensitive on the codec name"
);
assert_eq!(
codecs.fmtp_for("AMR-WB", 8000),
None,
"clock rate must also match"
);
assert_eq!(
codecs.fmtp_for("PCMU", 8000),
None,
"codec not in the offer"
);
}
}