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