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