Skip to main content

freeswitch_types/sdp/codecs/
mod.rs

1//! Collection of codecs parsed from a complete SDP session description.
2//!
3//! Line numbers in this module index FreeSWITCH `v1.11.1`
4//! (`c2c59645f6911a76589e5008c4d73349ded44b65`).
5
6mod 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/// A single entry in a parsed SDP offer.
22///
23/// Payloads negotiated outside the codec string are excluded from entries and
24/// retained whole by [`SdpMediaSection::non_codec_payloads`].
25#[derive(Debug, Clone)]
26#[non_exhaustive]
27pub enum SdpCodecEntry {
28    /// An RTP codec negotiated via `a=rtpmap` or the RFC 3551 static table.
29    Rtp(SdpCodec),
30    /// T.38 fax relay, derived from any `m=image` section.
31    ///
32    /// The proto and fmt fields are not inspected — FreeSWITCH negotiates T.38
33    /// parameters independently of the codec string. Whether the section reaches
34    /// a codec string is [`SdpMediaSection::is_negotiable`].
35    T38,
36}
37
38/// One `m=` section of an SDP offer, whatever its port or media type.
39///
40/// Sections that contribute nothing to a codec string are retained all the same:
41/// a port-0 section is the offer's held or declined stream and is exactly what a
42/// reader needs when a call has no audio. [`is_negotiable`](Self::is_negotiable)
43/// separates the two.
44#[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    /// The media type from the `m=` line.
58    pub fn media_type(&self) -> &SdpMediaType {
59        &self.media_type
60    }
61
62    /// The transport port; `0` means the peer declined or held this stream.
63    pub fn port(&self) -> u16 {
64        self.port
65    }
66
67    /// The transport protocol from the `m=` line, verbatim.
68    pub fn proto(&self) -> &str {
69        &self.proto
70    }
71
72    /// The `m=` format list, verbatim — the only content a non-RTP section carries.
73    pub fn formats(&self) -> &str {
74        &self.formats
75    }
76
77    /// The direction the peer wrote, defaulting to the session's.
78    ///
79    /// Sofia forces a port-0 section to inactive regardless, so on a held section
80    /// this is what was offered, not what the switch acted on.
81    pub fn direction(&self) -> SdpDirection {
82        self.direction
83    }
84
85    /// Codec entries in `m=` format-list order.
86    pub fn entries(&self) -> &[SdpCodecEntry] {
87        &self.entries
88    }
89
90    /// Payload types this section named that could not be resolved to a codec.
91    pub fn unmapped(&self) -> &[UnmappedPayload] {
92        &self.unmapped
93    }
94
95    /// Payloads the switch negotiates outside the codec string, in format-list order.
96    ///
97    /// Not deduplicated. See [`NonCodecPayload`] for what the switch itself retains.
98    pub fn non_codec_payloads(&self) -> &[NonCodecPayload] {
99        &self.non_codec
100    }
101
102    /// Whether this section feeds the codec string and the top-level views.
103    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/// Codecs and ancillary data extracted from an SDP session description.
111///
112/// Produced by [`SdpCodecs::parse`]. The accessors that feed a codec string reflect
113/// the same extraction logic as FreeSWITCH's
114/// `switch_core_media_set_r_sdp_codec_string`; [`sections`](Self::sections) is the
115/// wider view, every `m=` line the offer carried. See `docs/codec-string-format.md`
116/// for the mapping rules.
117#[derive(Debug, Clone)]
118pub struct SdpCodecs {
119    sections: Vec<SdpMediaSection>,
120    warnings: Vec<SdpWarning>,
121}
122
123impl SdpCodecs {
124    /// Parse an SDP session description from a UTF-8 string.
125    pub fn parse(sdp: &str) -> Result<Self, SdpCodecError> {
126        Self::parse_bytes(sdp.as_bytes())
127    }
128
129    /// Parse an SDP session description from bytes.
130    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        // Session-level defaults; media-level attributes override these per section.
143        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            // SdpMediaType::from_str is infallible (Err = std::convert::Infallible).
151            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                // The proto and fmt are not inspected; the section is the T.38 stream.
177                section
178                    .entries
179                    .push(SdpCodecEntry::T38);
180            } else if proto_has_rtp(&media.proto) {
181                // Staged locally: one structurally broken section must not discard
182                // what every other section already parsed.
183                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    /// Every `m=` section the offer carried, in offer order.
217    ///
218    /// This is the inventory, including the sections no codec string can reach — a
219    /// held or declined stream, or a media type the switch derives no codec from.
220    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    /// Codec entries from the negotiable sections, in SDP offer order.
231    pub fn entries(&self) -> impl Iterator<Item = &SdpCodecEntry> {
232        self.negotiable()
233            .flat_map(|s| {
234                s.entries
235                    .iter()
236            })
237    }
238
239    /// Number of codec entries; payloads from [`non_codec_payloads`](Self::non_codec_payloads)
240    /// are not counted.
241    pub fn len(&self) -> usize {
242        self.entries()
243            .count()
244    }
245
246    /// `true` when the offer negotiates no codecs, ignoring any non-codec payloads.
247    pub fn is_empty(&self) -> bool {
248        self.entries()
249            .next()
250            .is_none()
251    }
252
253    /// Iterator over audio RTP codecs only.
254    pub fn audio(&self) -> impl Iterator<Item = &SdpCodec> {
255        self.rtp_of(SdpMediaType::Audio)
256    }
257
258    /// Iterator over video RTP codecs only.
259    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    /// Payload types that could not be named (no `a=rtpmap`, no static-table entry).
272    ///
273    /// These are surfaced as data rather than an error so the caller can distinguish
274    /// "never offered" from "offered but unresolvable". A non-negotiable section's
275    /// unresolvable payloads stay on [`SdpMediaSection::unmapped`].
276    pub fn unmapped(&self) -> impl Iterator<Item = &UnmappedPayload> {
277        self.negotiable()
278            .flat_map(|s| {
279                s.unmapped
280                    .iter()
281            })
282    }
283
284    /// Recoverable parse warnings (e.g. unparseable numeric attributes).
285    ///
286    /// Covers every section, negotiable or not — the excluded ones are parsed rather
287    /// than skipped, so they warn where the switch has no occasion to.
288    pub fn warnings(&self) -> &[SdpWarning] {
289        &self.warnings
290    }
291
292    /// Payloads the switch negotiates outside the codec string, in `m=` order.
293    ///
294    /// Never included in [`entries`](Self::entries) or [`unmapped`](Self::unmapped),
295    /// and drawn from the negotiable sections only; a held section's are on
296    /// [`SdpMediaSection::non_codec_payloads`]. Not deduplicated: two sections offering
297    /// the same kind at one clock rate under different payload types yield two entries.
298    /// See [`NonCodecPayload`] for what the switch itself retains from these.
299    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    /// Build a FreeSWITCH codec string from this offer's audio codecs.
308    ///
309    /// Emits a literal `t38` entry for each `m=image` section the offer carried, in
310    /// its original m-line position among the audio entries — C writes `,t38` inside
311    /// the same per-m-line loop that emits audio codecs (`switch_core_media.c:13651`),
312    /// so an `m=image` section before an `m=audio` section puts `t38` first, not last.
313    /// Does not deduplicate — an offer carrying the AMR octet-aligned/bandwidth-efficient
314    /// pair yields two identical entries under default options (they differ only in
315    /// fmtp, which is off by default for audio). Call [`CodecString::dedup`] after
316    /// composing the final string.
317    ///
318    /// `warnings = None` is strict: any unrepresentable codec name or fmtp is `Err`.
319    /// `warnings = Some(acc)` is lenient: an unrepresentable fmtp is cleared (the codec
320    /// is still emitted) and an unrepresentable codec name is skipped (the codec is
321    /// dropped); both push a warning to `acc` rather than failing the whole call. An
322    /// offer past [`CodecString::MAX_SWITCH_ENTRIES`] is
323    /// [`CodecStringError::TooManyEntries`] in either mode — the limit is structural,
324    /// not a per-codec oddity.
325    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                // Infallible for this literal; propagated so a future rename of it
334                // surfaces instead of dropping the entry.
335                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    /// Build a FreeSWITCH codec string from this offer's video codecs.
350    ///
351    /// Selects straight from the video codecs, where the audio builder walks every entry
352    /// instead: only the audio string interleaves T.38, which has to keep its m-line
353    /// position. Does not deduplicate; see
354    /// [`audio_codec_string`](Self::audio_codec_string) for why. `warnings` follows the
355    /// same strict/lenient convention.
356    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    /// Look up the fmtp of an offered audio codec by name and clock rate.
371    ///
372    /// Drives `rtp_force_audio_fmtp` once the caller knows which codec ended up first
373    /// in its final, composed, filtered codec string — "first codec in the offer" is
374    /// routinely not that codec, so this takes the name and rate explicitly rather than
375    /// exposing a "primary" accessor that would often name the wrong payload map.
376    ///
377    /// Several payload types commonly share one name+rate with different fmtp — the
378    /// AMR octet-aligned/bandwidth-efficient pair is the recurring case. When they
379    /// disagree, the first one carrying an fmtp wins; a caller driving
380    /// `rtp_force_audio_fmtp` from this can otherwise pin octet-aligned when the
381    /// switch negotiated bandwidth-efficient.
382    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
393/// Convert one [`SdpCodec`] to a [`CodecStringEntry`], splitting the two failure classes
394/// `audio_codec_string`/`video_codec_string` must handle differently: a name that cannot
395/// be embedded at all (`Ok(None)` lenient / `Err` strict, the codec is skipped) versus an
396/// fmtp that cannot be embedded (the entry is still emitted with fmtp cleared).
397/// The fault, named without the value the error's own `Display` quotes back: these
398/// reasons describe a peer's bytes, not a codec string this caller wrote.
399fn 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                        // entry keeps no fmtp; qualifiers below still apply.
449                    }
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    // --- static payload table ---
465
466    #[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    // --- AMR-WB channel count normalization ---
482
483    #[test]
484    fn amrwb_no_channel_field_equals_explicit_one() {
485        // a=rtpmap without a channel field and a=rtpmap with /1 must yield the same
486        // channel count — both normalize to Some(1) for audio sections.
487        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        // RFC 4566 puts no ceiling on rtpmap's encoding parameters, so a count
512        // that overflows a byte is a legal offer, not a malformed rtpmap.
513        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    // --- ptime precedence ---
524
525    #[test]
526    fn ptime_opus_fmtp_overrides_session_ptime() {
527        // Sequential overwrite: a=ptime:20 sets ptime=20, then opus fmtp ptime=40
528        // overrides it. The final result must be 40.
529        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        // iLBC without fmtp overrides even an explicit a=ptime.
542        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    // --- section ordering ---
574
575    #[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    // --- IPv6 connection ---
599
600    #[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    // --- unknown media type ---
611
612    #[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        // application section contributes no codecs
620        assert_eq!(codecs.len(), 1);
621        let rtp = rtp_codec(codecs.entries());
622        assert!(codec_named(&rtp, "PCMU").is_some());
623    }
624
625    // --- telephone-event and CN ---
626
627    #[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        // Still excluded from entries() and distinguishable from an unresolvable payload.
669        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        // Two sections offering DTMF at one rate under different payload types: both
729        // survive, in offer order. Collapsing them would lose which side offered what.
730        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    // --- unmapped payload types ---
776
777    #[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        // PT 0 (PCMU) is still there; PT 99 is unmapped, not silently dropped
787        let rtp = rtp_codec(codecs.entries());
788        assert_eq!(rtp.len(), 1);
789        assert!(codec_named(&rtp, "PCMU").is_some());
790    }
791
792    // --- audio_codec_string / video_codec_string / fmtp_for ---
793
794    #[test]
795    fn amr_oa_be_pair_collapses_after_dedup() {
796        // Both AMR/8000 registrations differ only in fmtp; default options don't emit
797        // fmtp, so the two entries are identical and only dedup() tells them apart.
798        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        // Matches the AMR octet-aligned / bandwidth-efficient pair's shape: the
831        // first payload matching name+rate has no fmtp, the second does. The
832        // second's fmtp must not be shadowed by the first's absence.
833        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        // A comma in the a=rtpmap encoding name cannot be represented as a codec-string
1008        // entry at all — distinct from UnmappedPayload (no rtpmap name whatsoever).
1009        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    // --- fmtp_for ---
1031
1032    #[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}