1mod attrs;
7mod media_section;
8#[cfg(test)]
9mod testutil;
10
11use attrs::{direction_from_attrs, ptime_from_attrs};
12use media_section::{is_image, parse_media_section, proto_has_rtp, SessionDefaults};
13
14use crate::sdp::{
15 codec::{NonCodecPayload, SdpCodec, SdpDirection, SdpMediaType},
16 codec_string::{CodecString, CodecStringEntry},
17 error::{CodecStringError, SdpCodecError, SdpWarning, UnmappedPayload},
18 options::CodecStringOptions,
19};
20
21#[derive(Debug, Clone)]
26#[non_exhaustive]
27pub enum SdpCodecEntry {
28 Rtp(SdpCodec),
30 T38,
36}
37
38#[derive(Debug, Clone)]
46pub struct SdpMediaSection {
47 media_type: SdpMediaType,
48 port: u16,
49 proto: String,
50 formats: String,
51 direction: SdpDirection,
52 entries: Vec<SdpCodecEntry>,
53 unmapped: Vec<UnmappedPayload>,
54 non_codec: Vec<NonCodecPayload>,
55}
56
57impl SdpMediaSection {
58 pub fn media_type(&self) -> &SdpMediaType {
60 &self.media_type
61 }
62
63 pub fn port(&self) -> u16 {
65 self.port
66 }
67
68 pub fn proto(&self) -> &str {
70 &self.proto
71 }
72
73 pub fn formats(&self) -> &str {
75 &self.formats
76 }
77
78 pub fn direction(&self) -> SdpDirection {
83 self.direction
84 }
85
86 pub fn entries(&self) -> &[SdpCodecEntry] {
88 &self.entries
89 }
90
91 pub fn unmapped(&self) -> &[UnmappedPayload] {
93 &self.unmapped
94 }
95
96 pub fn non_codec_payloads(&self) -> &[NonCodecPayload] {
100 &self.non_codec
101 }
102
103 pub fn is_negotiable(&self) -> bool {
105 let derives_entries = matches!(self.media_type, SdpMediaType::Audio | SdpMediaType::Video)
106 || is_image(&self.media_type);
107 self.port != 0 && derives_entries
108 }
109}
110
111#[derive(Debug, Clone)]
119pub struct SdpCodecs {
120 sections: Vec<SdpMediaSection>,
121 warnings: Vec<SdpWarning>,
122}
123
124impl SdpCodecs {
125 pub fn parse(sdp: &str) -> Result<Self, SdpCodecError> {
127 Self::parse_bytes(sdp.as_bytes())
128 }
129
130 pub fn parse_bytes(bytes: &[u8]) -> Result<Self, SdpCodecError> {
132 let session = sdp_types::Session::parse(bytes)
133 .map_err(|e| SdpCodecError::parse_failure("failed to parse SDP", e))?;
134 Self::from_session(&session)
135 }
136
137 fn from_session(session: &sdp_types::Session) -> Result<Self, SdpCodecError> {
138 let mut result = Self {
139 sections: Vec::new(),
140 warnings: Vec::new(),
141 };
142
143 let session_ptime = ptime_from_attrs(&session.attributes, "ptime", &mut result.warnings);
145 let session_maxptime =
146 ptime_from_attrs(&session.attributes, "maxptime", &mut result.warnings);
147 let session_direction = direction_from_attrs(&session.attributes, &mut result.warnings)
148 .unwrap_or(SdpDirection::SendRecv);
149
150 for media in &session.medias {
151 let media_type: SdpMediaType = match media
153 .media
154 .parse()
155 {
156 Ok(t) => t,
157 Err(e) => match e {},
158 };
159
160 let mut section = SdpMediaSection {
161 media_type: media_type.clone(),
162 port: media.port,
163 proto: media
164 .proto
165 .clone(),
166 formats: media
167 .fmt
168 .clone(),
169 direction: direction_from_attrs(&media.attributes, &mut result.warnings)
170 .unwrap_or(session_direction),
171 entries: Vec::new(),
172 unmapped: Vec::new(),
173 non_codec: Vec::new(),
174 };
175
176 if is_image(&media_type) {
177 section
179 .entries
180 .push(SdpCodecEntry::T38);
181 } else if proto_has_rtp(&media.proto) {
182 let defaults = SessionDefaults {
185 ptime: session_ptime,
186 maxptime: session_maxptime,
187 direction: section.direction,
188 };
189 match parse_media_section(media, media_type.clone(), &defaults) {
190 Ok(parsed) => {
191 section.entries = parsed.entries;
192 section.unmapped = parsed.unmapped;
193 section.non_codec = parsed.non_codec;
194 result
195 .warnings
196 .extend(parsed.warnings);
197 }
198 Err(e) => {
199 result
200 .warnings
201 .push(SdpWarning::malformed_media_section(
202 media_type.to_string(),
203 e.to_string(),
204 ));
205 }
206 }
207 }
208
209 result
210 .sections
211 .push(section);
212 }
213
214 Ok(result)
215 }
216
217 pub fn sections(&self) -> &[SdpMediaSection] {
222 &self.sections
223 }
224
225 fn negotiable(&self) -> impl Iterator<Item = &SdpMediaSection> {
226 self.sections
227 .iter()
228 .filter(|s| s.is_negotiable())
229 }
230
231 pub fn entries(&self) -> impl Iterator<Item = &SdpCodecEntry> {
233 self.negotiable()
234 .flat_map(|s| {
235 s.entries
236 .iter()
237 })
238 }
239
240 pub fn len(&self) -> usize {
243 self.entries()
244 .count()
245 }
246
247 pub fn is_empty(&self) -> bool {
249 self.entries()
250 .next()
251 .is_none()
252 }
253
254 pub fn audio(&self) -> impl Iterator<Item = &SdpCodec> {
256 self.rtp_of(SdpMediaType::Audio)
257 }
258
259 pub fn video(&self) -> impl Iterator<Item = &SdpCodec> {
261 self.rtp_of(SdpMediaType::Video)
262 }
263
264 fn rtp_of(&self, media: SdpMediaType) -> impl Iterator<Item = &SdpCodec> {
265 self.entries()
266 .filter_map(move |e| match e {
267 SdpCodecEntry::Rtp(c) if c.media() == &media => Some(c),
268 _ => None,
269 })
270 }
271
272 pub fn unmapped(&self) -> impl Iterator<Item = &UnmappedPayload> {
278 self.negotiable()
279 .flat_map(|s| {
280 s.unmapped
281 .iter()
282 })
283 }
284
285 pub fn warnings(&self) -> &[SdpWarning] {
290 &self.warnings
291 }
292
293 pub fn non_codec_payloads(&self) -> impl Iterator<Item = &NonCodecPayload> {
301 self.negotiable()
302 .flat_map(|s| {
303 s.non_codec
304 .iter()
305 })
306 }
307
308 pub fn audio_codec_string(
327 &self,
328 options: &CodecStringOptions,
329 mut warnings: Option<&mut Vec<SdpWarning>>,
330 ) -> Result<CodecString, CodecStringError> {
331 let mut out = CodecString::new();
332 for entry in self.entries() {
333 match entry {
334 SdpCodecEntry::T38 => out.push(CodecStringEntry::new("t38")?)?,
337 SdpCodecEntry::Rtp(codec) if codec.media() == &SdpMediaType::Audio => {
338 if let Some(entry) =
339 codec_to_entry_lenient(codec, options, warnings.as_deref_mut())?
340 {
341 out.push(entry)?;
342 }
343 }
344 SdpCodecEntry::Rtp(_) => {}
345 }
346 }
347 Ok(out)
348 }
349
350 pub fn video_codec_string(
358 &self,
359 options: &CodecStringOptions,
360 mut warnings: Option<&mut Vec<SdpWarning>>,
361 ) -> Result<CodecString, CodecStringError> {
362 let mut out = CodecString::new();
363 for codec in self.video() {
364 if let Some(entry) = codec_to_entry_lenient(codec, options, warnings.as_deref_mut())? {
365 out.push(entry)?;
366 }
367 }
368 Ok(out)
369 }
370
371 pub fn fmtp_for(&self, name: &str, clock_rate: u32) -> Option<&str> {
384 self.audio()
385 .filter(|c| {
386 c.name()
387 .eq_ignore_ascii_case(name)
388 && c.clock_rate() == clock_rate
389 })
390 .find_map(|c| c.fmtp())
391 }
392}
393
394fn unrepresentable_reason(e: &CodecStringError) -> &'static str {
401 match e {
402 CodecStringError::AtInFmtp(_) => "contains '@', which the qualifier split takes first",
403 CodecStringError::DotInFmtpWithoutModule(_) => {
404 "contains '.' with no module prefix, which the modname split takes first"
405 }
406 CodecStringError::WireInjection { .. } => "contains a newline or a carriage return",
407 CodecStringError::InvalidCharInName { .. } => "contains a codec-string grammar delimiter",
408 CodecStringError::InvalidCodecName(_) => "is empty",
409 _ => "has no representation in the codec-string grammar",
410 }
411}
412
413fn codec_to_entry_lenient(
414 codec: &SdpCodec,
415 options: &CodecStringOptions,
416 mut warnings: Option<&mut Vec<SdpWarning>>,
417) -> Result<Option<CodecStringEntry>, CodecStringError> {
418 let mut entry = match CodecStringEntry::new(codec.name()) {
419 Ok(e) => e,
420 Err(e) => {
421 return match warnings.as_deref_mut() {
422 None => Err(e),
423 Some(acc) => {
424 acc.push(SdpWarning::codec_name_unrepresentable(
425 codec.name(),
426 unrepresentable_reason(&e),
427 ));
428 Ok(None)
429 }
430 };
431 }
432 };
433
434 if options.emits_fmtp() {
435 if let Some(fmtp) = codec.fmtp() {
436 match entry
437 .clone()
438 .with_fmtp(fmtp)
439 {
440 Ok(with_fmtp) => entry = with_fmtp,
441 Err(e) => match warnings {
442 None => return Err(e),
443 Some(acc) => {
444 acc.push(SdpWarning::fmtp_unrepresentable(
445 codec.name(),
446 fmtp,
447 unrepresentable_reason(&e),
448 ));
449 }
451 },
452 }
453 }
454 }
455
456 Ok(Some(options.apply_qualifiers(entry, codec)))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::testutil::{codec_named, rtp_codec, sdp_header};
462 use super::*;
463 use crate::sdp::NonCodecKind;
464
465 #[test]
468 fn static_only_pcmu_pcma_g729_no_rtpmap() {
469 let sdp = format!("{}m=audio 5004 RTP/AVP 0 8 18\r\n", sdp_header());
470 let codecs = SdpCodecs::parse(&sdp).unwrap();
471 let entries = codecs.entries();
472 let rtp = rtp_codec(entries);
473 assert_eq!(rtp.len(), 3);
474 assert!(codec_named(&rtp, "PCMU").is_some());
475 assert!(codec_named(&rtp, "PCMA").is_some());
476 assert!(codec_named(&rtp, "G729").is_some());
477 for c in &rtp {
478 assert!(!c.has_rtpmap(), "static types must have has_rtpmap=false");
479 }
480 }
481
482 #[test]
485 fn amrwb_no_channel_field_equals_explicit_one() {
486 let sdp_no_ch = format!(
489 "{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 AMR-WB/16000\r\n",
490 sdp_header()
491 );
492 let sdp_ch1 = format!(
493 "{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 AMR-WB/16000/1\r\n",
494 sdp_header()
495 );
496 let c_no_ch = SdpCodecs::parse(&sdp_no_ch).unwrap();
497 let c_ch1 = SdpCodecs::parse(&sdp_ch1).unwrap();
498 let rtp_no_ch = rtp_codec(c_no_ch.entries());
499 let rtp_ch1 = rtp_codec(c_ch1.entries());
500 let amrwb_no_ch = codec_named(&rtp_no_ch, "AMR-WB").expect("AMR-WB must be present");
501 let amrwb_ch1 = codec_named(&rtp_ch1, "AMR-WB").expect("AMR-WB must be present");
502 assert_eq!(
503 amrwb_no_ch.channels(),
504 amrwb_ch1.channels(),
505 "missing /1 and explicit /1 must normalize to the same channel count"
506 );
507 assert_eq!(amrwb_ch1.channels(), Some(1));
508 }
509
510 #[test]
511 fn a_channel_count_above_a_byte_parses() {
512 let sdp = format!(
515 "{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 L16/48000/300\r\n",
516 sdp_header()
517 );
518 let codecs = SdpCodecs::parse(&sdp).expect("a legal channel count must not fail the parse");
519 let rtp = rtp_codec(codecs.entries());
520 let l16 = codec_named(&rtp, "L16").expect("L16 must be present");
521 assert_eq!(l16.channels(), Some(300));
522 }
523
524 #[test]
527 fn ptime_opus_fmtp_overrides_session_ptime() {
528 let sdp = format!(
531 "{}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",
532 sdp_header()
533 );
534 let codecs = SdpCodecs::parse(&sdp).unwrap();
535 let rtp = rtp_codec(codecs.entries());
536 let opus = codec_named(&rtp, "opus").expect("opus must be present");
537 assert_eq!(opus.ptime(), Some(40), "fmtp ptime= must override a=ptime");
538 }
539
540 #[test]
541 fn ptime_ilbc_no_fmtp_overrides_explicit_ptime() {
542 let sdp = format!(
544 "{}m=audio 5004 RTP/AVP 97\r\na=rtpmap:97 iLBC/8000\r\na=ptime:20\r\n",
545 sdp_header()
546 );
547 let codecs = SdpCodecs::parse(&sdp).unwrap();
548 let rtp = rtp_codec(codecs.entries());
549 let ilbc = codec_named(&rtp, "iLBC").expect("iLBC must be present");
550 assert_eq!(
551 ilbc.ptime(),
552 Some(30),
553 "iLBC without fmtp must always yield ptime=30"
554 );
555 }
556
557 #[test]
558 fn ptime_ilbc_fmtp_mode20_yields_20_no_bitrate() {
559 let sdp = format!(
560 "{}m=audio 5004 RTP/AVP 97\r\na=rtpmap:97 iLBC/8000\r\na=fmtp:97 mode=20\r\n",
561 sdp_header()
562 );
563 let codecs = SdpCodecs::parse(&sdp).unwrap();
564 let rtp = rtp_codec(codecs.entries());
565 let ilbc = codec_named(&rtp, "iLBC").expect("iLBC must be present");
566 assert_eq!(ilbc.ptime(), Some(20));
567 assert_eq!(
568 ilbc.bitrate(),
569 None,
570 "fmtp present — bitrate not set by static table"
571 );
572 }
573
574 #[test]
577 fn sections_keep_m_line_order_across_negotiable_and_not() {
578 let sdp = format!(
579 concat!(
580 "{}",
581 "m=audio 5004 RTP/AVP 0\r\n",
582 "m=video 0 RTP/AVP 99\r\n",
583 "a=rtpmap:99 H264/90000\r\n"
584 ),
585 sdp_header()
586 );
587 let codecs = SdpCodecs::parse(&sdp).unwrap();
588 let sections = codecs.sections();
589 assert_eq!(sections[0].media_type(), &SdpMediaType::Audio);
590 assert!(sections[0].is_negotiable());
591 assert_eq!(sections[1].media_type(), &SdpMediaType::Video);
592 assert!(!sections[1].is_negotiable());
593
594 let rtp = rtp_codec(codecs.entries());
595 assert_eq!(rtp.len(), 1, "the declined video must not reach entries()");
596 assert!(codec_named(&rtp, "PCMU").is_some());
597 }
598
599 #[test]
602 fn ipv6_connection_parses_without_error() {
603 let sdp = format!(
604 "{}c=IN IP6 2001:db8::1\r\nm=audio 5004 RTP/AVP 0\r\n",
605 sdp_header()
606 );
607 let codecs = SdpCodecs::parse(&sdp).unwrap();
608 assert_eq!(codecs.len(), 1);
609 }
610
611 #[test]
614 fn application_m_line_does_not_fail() {
615 let sdp = format!(
616 "{}m=application 9 UDP/BFCP *\r\nm=audio 5004 RTP/AVP 0\r\n",
617 sdp_header()
618 );
619 let codecs = SdpCodecs::parse(&sdp).unwrap();
620 assert_eq!(codecs.len(), 1);
622 let rtp = rtp_codec(codecs.entries());
623 assert!(codec_named(&rtp, "PCMU").is_some());
624 }
625
626 #[test]
629 fn telephone_event_retains_payload_type_rate_and_fmtp() {
630 let sdp = format!(
631 concat!(
632 "{}",
633 "m=audio 5004 RTP/AVP 0 101 102\r\n",
634 "a=rtpmap:101 telephone-event/8000\r\n",
635 "a=fmtp:101 0-16\r\n",
636 "a=rtpmap:102 telephone-event/16000\r\n"
637 ),
638 sdp_header()
639 );
640 let codecs = SdpCodecs::parse(&sdp).unwrap();
641 let te: Vec<_> = codecs
642 .non_codec_payloads()
643 .collect();
644 assert_eq!(
645 te.len(),
646 2,
647 "both telephone-event payloads must be retained"
648 );
649
650 assert_eq!(te[0].kind, NonCodecKind::TelephoneEvent);
651 assert_eq!(te[0].payload_type, 101);
652 assert_eq!(te[0].clock_rate, 8000);
653 assert_eq!(
654 te[0]
655 .fmtp
656 .as_deref(),
657 Some("0-16"),
658 "the offered digit range is what an operator reads on a DTMF fault"
659 );
660 assert!(te[0].has_rtpmap);
661 assert_eq!(te[0].media_type, SdpMediaType::Audio);
662
663 assert_eq!(te[1].payload_type, 102);
664 assert_eq!(te[1].clock_rate, 16000);
665 assert!(te[1]
666 .fmtp
667 .is_none());
668
669 let rtp = rtp_codec(codecs.entries());
671 assert!(codec_named(&rtp, "telephone-event").is_none());
672 assert_eq!(
673 codecs
674 .unmapped()
675 .count(),
676 0
677 );
678 }
679
680 #[test]
681 fn comfort_noise_from_static_table_has_no_rtpmap() {
682 let sdp = format!("{}m=audio 5004 RTP/AVP 0 13\r\n", sdp_header());
683 let codecs = SdpCodecs::parse(&sdp).unwrap();
684 let cn: Vec<_> = codecs
685 .non_codec_payloads()
686 .collect();
687 assert_eq!(cn.len(), 1);
688 assert_eq!(cn[0].kind, NonCodecKind::ComfortNoise);
689 assert_eq!(cn[0].payload_type, 13);
690 assert_eq!(cn[0].clock_rate, 8000);
691 assert!(cn[0]
692 .fmtp
693 .is_none());
694 assert!(
695 !cn[0].has_rtpmap,
696 "PT 13 resolved from the RFC 3551 table, not declared by the peer"
697 );
698
699 let rtp = rtp_codec(codecs.entries());
700 assert!(codec_named(&rtp, "CN").is_none());
701 assert_eq!(
702 codecs
703 .unmapped()
704 .count(),
705 0
706 );
707 }
708
709 #[test]
710 fn comfort_noise_with_explicit_rtpmap_is_distinguishable() {
711 let sdp = format!(
712 concat!(
713 "{}",
714 "m=audio 5004 RTP/AVP 0 13\r\n",
715 "a=rtpmap:13 CN/8000\r\n"
716 ),
717 sdp_header()
718 );
719 let codecs = SdpCodecs::parse(&sdp).unwrap();
720 let cn: Vec<_> = codecs
721 .non_codec_payloads()
722 .collect();
723 assert_eq!(cn.len(), 1);
724 assert!(cn[0].has_rtpmap);
725 }
726
727 #[test]
728 fn non_codec_payloads_are_an_inventory_not_a_set() {
729 let sdp = format!(
732 concat!(
733 "{}",
734 "m=audio 5004 RTP/AVP 0 101\r\n",
735 "a=rtpmap:101 telephone-event/8000\r\n",
736 "m=audio 5006 RTP/AVP 8 96\r\n",
737 "a=rtpmap:96 telephone-event/8000\r\n"
738 ),
739 sdp_header()
740 );
741 let codecs = SdpCodecs::parse(&sdp).unwrap();
742 let te: Vec<_> = codecs
743 .non_codec_payloads()
744 .collect();
745 assert_eq!(te.len(), 2);
746 assert_eq!(te[0].payload_type, 101);
747 assert_eq!(te[1].payload_type, 96);
748 }
749
750 #[test]
751 fn non_codec_payloads_never_reach_the_codec_string() {
752 let sdp = format!(
753 concat!(
754 "{}",
755 "m=audio 5004 RTP/AVP 0 13 101\r\n",
756 "a=rtpmap:101 telephone-event/8000\r\n"
757 ),
758 sdp_header()
759 );
760 let codecs = SdpCodecs::parse(&sdp).unwrap();
761 assert_eq!(
762 codecs
763 .non_codec_payloads()
764 .count(),
765 2
766 );
767 let cs = codecs
768 .audio_codec_string(&CodecStringOptions::default(), None)
769 .unwrap();
770 assert_eq!(cs.len(), 1, "only PCMU may reach the codec string");
771 let rendered = cs.to_string();
772 assert!(!rendered.contains("telephone-event"));
773 assert!(!rendered.contains("CN"));
774 }
775
776 #[test]
779 fn dynamic_pt_without_rtpmap_is_unmapped_not_error() {
780 let sdp = format!("{}m=audio 5004 RTP/AVP 0 99\r\n", sdp_header());
781 let codecs = SdpCodecs::parse(&sdp).unwrap();
782 let unmapped: Vec<_> = codecs
783 .unmapped()
784 .collect();
785 assert_eq!(unmapped.len(), 1);
786 assert_eq!(unmapped[0].payload_type, 99);
787 let rtp = rtp_codec(codecs.entries());
789 assert_eq!(rtp.len(), 1);
790 assert!(codec_named(&rtp, "PCMU").is_some());
791 }
792
793 #[test]
796 fn amr_oa_be_pair_collapses_after_dedup() {
797 let sdp = format!(
800 concat!(
801 "{}",
802 "m=audio 5004 RTP/AVP 96 97\r\n",
803 "a=rtpmap:96 AMR/8000\r\n",
804 "a=fmtp:96 octet-align=1\r\n",
805 "a=rtpmap:97 AMR/8000\r\n",
806 "a=fmtp:97 octet-align=0\r\n"
807 ),
808 sdp_header()
809 );
810 let codecs = SdpCodecs::parse(&sdp).unwrap();
811 let cs = codecs
812 .audio_codec_string(&CodecStringOptions::default(), None)
813 .unwrap();
814 assert_eq!(cs.len(), 2, "both AMR entries must be emitted");
815 assert_eq!(
816 cs.entries()[0],
817 cs.entries()[1],
818 "entries must be identical under default options (no fmtp)"
819 );
820 let mut deduped = cs.clone();
821 deduped.dedup();
822 assert_eq!(
823 deduped.len(),
824 1,
825 "duplicate AMR entries must collapse under dedup()"
826 );
827 }
828
829 #[test]
830 fn fmtp_for_falls_through_to_later_codec_with_fmtp() {
831 let sdp = format!(
835 concat!(
836 "{}",
837 "m=audio 5004 RTP/AVP 97 96\r\n",
838 "a=rtpmap:97 AMR/8000\r\n",
839 "a=rtpmap:96 AMR/8000\r\n",
840 "a=fmtp:96 octet-align=1\r\n"
841 ),
842 sdp_header()
843 );
844 let codecs = SdpCodecs::parse(&sdp).unwrap();
845 assert_eq!(
846 codecs.fmtp_for("AMR", 8000),
847 Some("octet-align=1"),
848 "fmtp_for must fall through a fmtp-less match to a later one that has fmtp"
849 );
850 }
851
852 #[test]
853 fn amrwb_absent_and_explicit_channel_collapse_after_dedup() {
854 let sdp = format!(
855 concat!(
856 "{}",
857 "m=audio 5004 RTP/AVP 100 101\r\n",
858 "a=rtpmap:100 AMR-WB/16000\r\n",
859 "a=rtpmap:101 AMR-WB/16000/1\r\n"
860 ),
861 sdp_header()
862 );
863 let codecs = SdpCodecs::parse(&sdp).unwrap();
864 let cs = codecs
865 .audio_codec_string(&CodecStringOptions::default(), None)
866 .unwrap();
867 assert_eq!(cs.len(), 2);
868 let mut deduped = cs.clone();
869 deduped.dedup();
870 assert_eq!(
871 deduped.len(),
872 1,
873 "absent and explicit /1 channel count must normalize identically"
874 );
875 }
876
877 #[test]
878 fn evs_dotted_fmtp_unreachable_under_default_options() {
879 let sdp = format!(
880 concat!(
881 "{}",
882 "m=audio 5004 RTP/AVP 110\r\n",
883 "a=rtpmap:110 EVS/16000\r\n",
884 "a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
885 ),
886 sdp_header()
887 );
888 let codecs = SdpCodecs::parse(&sdp).unwrap();
889 let cs = codecs
890 .audio_codec_string(&CodecStringOptions::default(), None)
891 .unwrap();
892 assert_eq!(cs.len(), 1);
893 assert!(
894 cs.entries()[0]
895 .fmtp()
896 .is_none(),
897 "fmtp emission is off by default; the dotted fmtp path is unreachable"
898 );
899 }
900
901 #[test]
902 fn evs_dotted_fmtp_lenient_clears_and_warns() {
903 let sdp = format!(
904 concat!(
905 "{}",
906 "m=audio 5004 RTP/AVP 110\r\n",
907 "a=rtpmap:110 EVS/16000\r\n",
908 "a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
909 ),
910 sdp_header()
911 );
912 let codecs = SdpCodecs::parse(&sdp).unwrap();
913 let opts = CodecStringOptions::default().with_fmtp(true);
914 let mut warnings = Vec::new();
915 let cs = codecs
916 .audio_codec_string(&opts, Some(&mut warnings))
917 .unwrap();
918 assert_eq!(cs.len(), 1, "the codec must still be emitted");
919 assert!(
920 cs.entries()[0]
921 .fmtp()
922 .is_none(),
923 "unrepresentable fmtp must be cleared, not left dangling"
924 );
925 assert_eq!(warnings.len(), 1);
926 assert!(matches!(
927 warnings[0],
928 SdpWarning::FmtpUnrepresentable { .. }
929 ));
930 }
931
932 #[test]
933 fn evs_dotted_fmtp_strict_is_err() {
934 let sdp = format!(
935 concat!(
936 "{}",
937 "m=audio 5004 RTP/AVP 110\r\n",
938 "a=rtpmap:110 EVS/16000\r\n",
939 "a=fmtp:110 br=13.2-24.4;bw=nb-swb\r\n"
940 ),
941 sdp_header()
942 );
943 let codecs = SdpCodecs::parse(&sdp).unwrap();
944 let opts = CodecStringOptions::default().with_fmtp(true);
945 let result = codecs.audio_codec_string(&opts, None);
946 assert!(
947 result.is_err(),
948 "strict mode must fail on an unrepresentable fmtp"
949 );
950 }
951
952 #[test]
953 fn g722_rate_is_8000_never_16000() {
954 let sdp = format!(
955 "{}m=audio 5004 RTP/AVP 9\r\na=rtpmap:9 G722/8000\r\n",
956 sdp_header()
957 );
958 let codecs = SdpCodecs::parse(&sdp).unwrap();
959 let cs = codecs
960 .audio_codec_string(&CodecStringOptions::default(), None)
961 .unwrap();
962 assert_eq!(cs.len(), 1);
963 assert_eq!(cs.entries()[0].name(), "G722");
964 assert_eq!(cs.entries()[0].rate(), Some(8000));
965 assert_eq!(cs.entries()[0].ptime(), Some(20));
966 }
967
968 #[test]
969 fn video_codec_string_carries_no_rate_qualifier() {
970 let sdp = format!(
971 concat!(
972 "{}",
973 "m=audio 5004 RTP/AVP 0\r\n",
974 "m=video 5006 RTP/AVP 96\r\n",
975 "a=rtpmap:96 H264/90000\r\n",
976 "m=image 5008 udptl t38\r\n"
977 ),
978 sdp_header()
979 );
980 let codecs = SdpCodecs::parse(&sdp).unwrap();
981
982 let video_cs = codecs
983 .video_codec_string(&CodecStringOptions::video(), None)
984 .unwrap();
985 assert_eq!(video_cs.len(), 1);
986 assert_eq!(video_cs.entries()[0].name(), "H264");
987 assert!(
988 video_cs.entries()[0]
989 .rate()
990 .is_none(),
991 "CodecStringOptions::video() must not emit a rate qualifier"
992 );
993
994 let audio_cs = codecs
995 .audio_codec_string(&CodecStringOptions::default(), None)
996 .unwrap();
997 assert!(
998 audio_cs
999 .entries()
1000 .iter()
1001 .any(|e| e.name() == "t38"),
1002 "an offered m=image section must append a literal t38 entry to the audio string"
1003 );
1004 }
1005
1006 #[test]
1007 fn codec_name_with_delimiter_is_skipped_lenient_and_erred_strict() {
1008 let sdp = format!(
1011 "{}m=audio 5004 RTP/AVP 100\r\na=rtpmap:100 bad,name/8000\r\n",
1012 sdp_header()
1013 );
1014 let codecs = SdpCodecs::parse(&sdp).unwrap();
1015
1016 let mut warnings = Vec::new();
1017 let cs = codecs
1018 .audio_codec_string(&CodecStringOptions::default(), Some(&mut warnings))
1019 .unwrap();
1020 assert!(cs.is_empty(), "the unrepresentable codec must be skipped");
1021 assert_eq!(warnings.len(), 1);
1022 assert!(matches!(
1023 warnings[0],
1024 SdpWarning::CodecNameUnrepresentable { .. }
1025 ));
1026
1027 let result = codecs.audio_codec_string(&CodecStringOptions::default(), None);
1028 assert!(result.is_err(), "strict mode must fail");
1029 }
1030
1031 #[test]
1034 fn fmtp_for_looks_up_by_name_and_rate_case_insensitively() {
1035 let sdp = format!(
1036 concat!(
1037 "{}",
1038 "m=audio 5004 RTP/AVP 100\r\n",
1039 "a=rtpmap:100 AMR-WB/16000\r\n",
1040 "a=fmtp:100 octet-align=1\r\n"
1041 ),
1042 sdp_header()
1043 );
1044 let codecs = SdpCodecs::parse(&sdp).unwrap();
1045 assert_eq!(
1046 codecs.fmtp_for("amr-wb", 16000),
1047 Some("octet-align=1"),
1048 "lookup must be case-insensitive on the codec name"
1049 );
1050 assert_eq!(
1051 codecs.fmtp_for("AMR-WB", 8000),
1052 None,
1053 "clock rate must also match"
1054 );
1055 assert_eq!(
1056 codecs.fmtp_for("PCMU", 8000),
1057 None,
1058 "codec not in the offer"
1059 );
1060 }
1061}