Skip to main content

freeswitch_log_parser/
codec.rs

1//! Codec descriptors from `switch_core_media.c`'s negotiation trace.
2//!
3//! The audio and video forms carry different fields, so a token is only
4//! meaningful together with the media type of the line it came from.
5
6use std::fmt;
7
8/// Which negotiation trace a codec token came from.
9#[non_exhaustive]
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum CodecMedia {
12    Audio,
13    Video,
14}
15
16impl fmt::Display for CodecMedia {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            CodecMedia::Audio => f.pad("audio"),
20            CodecMedia::Video => f.pad("video"),
21        }
22    }
23}
24
25/// One codec as FreeSWITCH spells it inside a negotiation trace's brackets.
26///
27/// Everything past the payload type is `None` for video, whose trace carries
28/// only `name:payload_type`.
29#[non_exhaustive]
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CodecOffer {
32    pub name: String,
33    pub payload_type: u8,
34    pub clock_rate: Option<u32>,
35    pub ptime: Option<u32>,
36    pub bitrate: Option<u32>,
37    pub channels: Option<u8>,
38}
39
40impl fmt::Display for CodecOffer {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(f, "{}:{}", self.name, self.payload_type)?;
43        if let Some(rate) = self.clock_rate {
44            write!(f, " {rate}Hz")?;
45        }
46        if let Some(ptime) = self.ptime {
47            write!(f, " {ptime}ms")?;
48        }
49        if let Some(bitrate) = self.bitrate {
50            write!(f, " {bitrate}b")?;
51        }
52        if let Some(channels) = self.channels {
53            write!(f, " {channels}ch")?;
54        }
55        Ok(())
56    }
57}
58
59/// Why a codec token could not be read.
60#[non_exhaustive]
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum CodecParseError {
63    /// Field count matches no known form for this media type.
64    Arity { media: CodecMedia, fields: usize },
65    /// A numeric field was not a number.
66    NotNumeric { field: &'static str },
67    /// The codec name was empty.
68    EmptyName,
69}
70
71impl fmt::Display for CodecParseError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            CodecParseError::Arity { media, fields } => {
75                write!(f, "{fields} fields is not a {media} codec token")
76            }
77            CodecParseError::NotNumeric { field } => write!(f, "non-numeric {field}"),
78            CodecParseError::EmptyName => f.write_str("empty codec name"),
79        }
80    }
81}
82
83impl std::error::Error for CodecParseError {}
84
85impl CodecOffer {
86    /// Read a bracket-stripped token, e.g. `opus:116:16000:20:0:1` for audio or
87    /// `H264:109` for video.
88    ///
89    /// The seven-field audio form is `switch_core_media.c:5575`'s only, which
90    /// logs `actual_samples_per_second` after the rate where its siblings do
91    /// not; that extra field is skipped so all audio forms yield the same shape.
92    pub fn parse(media: CodecMedia, token: &str) -> Result<Self, CodecParseError> {
93        let mut fields = token.split(':');
94        let name = fields.next().unwrap_or_default();
95        if name.is_empty() {
96            return Err(CodecParseError::EmptyName);
97        }
98        let rest: Vec<&str> = fields.collect();
99
100        let num = |raw: &str, field: &'static str| -> Result<u32, CodecParseError> {
101            raw.parse()
102                .map_err(|_| CodecParseError::NotNumeric { field })
103        };
104        let payload_type = |raw: &str| -> Result<u8, CodecParseError> {
105            raw.parse().map_err(|_| CodecParseError::NotNumeric {
106                field: "payload type",
107            })
108        };
109
110        let arity = |fields: usize| CodecParseError::Arity { media, fields };
111
112        match (media, rest.as_slice()) {
113            (CodecMedia::Video, [pt]) => Ok(CodecOffer {
114                name: name.to_string(),
115                payload_type: payload_type(pt)?,
116                clock_rate: None,
117                ptime: None,
118                bitrate: None,
119                channels: None,
120            }),
121            (CodecMedia::Audio, [pt, rate, ptime, bitrate, channels])
122            | (CodecMedia::Audio, [pt, rate, _, ptime, bitrate, channels]) => Ok(CodecOffer {
123                name: name.to_string(),
124                payload_type: payload_type(pt)?,
125                clock_rate: Some(num(rate, "clock rate")?),
126                ptime: Some(num(ptime, "ptime")?),
127                bitrate: Some(num(bitrate, "bitrate")?),
128                channels: Some(
129                    num(channels, "channels")?
130                        .try_into()
131                        .map_err(|_| CodecParseError::NotNumeric { field: "channels" })?,
132                ),
133            }),
134            _ => Err(arity(rest.len() + 1)),
135        }
136    }
137}
138
139/// One line of the negotiation trace, once its bracketed tokens are read.
140///
141/// `switch_core_media.c` logs a comparison for every remote/local pair it
142/// considers, then a verdict line per codec it keeps or drops.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub(crate) enum CodecTrace {
145    Compared {
146        offered: CodecOffer,
147        local: CodecOffer,
148    },
149    Matched(CodecOffer),
150    NearMatched(CodecOffer),
151    /// Kept out of the block: the codec exceeded the engine's match limit.
152    /// Read anyway so a malformed token still warns rather than passing silently.
153    Dropped(CodecOffer),
154}
155
156/// Why a negotiation trace line could not be read.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub(crate) enum CodecTraceError {
159    /// The line matched no known trace shape.
160    UnknownShape,
161    /// A bracketed token would not parse.
162    BadToken(CodecParseError),
163}
164
165impl From<CodecTraceError> for Option<CodecParseError> {
166    fn from(e: CodecTraceError) -> Self {
167        match e {
168            CodecTraceError::UnknownShape => None,
169            CodecTraceError::BadToken(e) => Some(e),
170        }
171    }
172}
173
174impl CodecTrace {
175    /// Read a whole trace line, e.g. `Audio Codec Compare [opus:116:48000:20:0:1]/[opus:…]`
176    /// or `Audio Codec Compare [opus:…] ++++ is saved as a match`.
177    ///
178    /// The media type comes from the line's own prefix via
179    /// [`classify_message`](crate::classify_message), so it is passed in rather
180    /// than re-read here.
181    pub(crate) fn parse(media: CodecMedia, msg: &str) -> Result<Self, CodecTraceError> {
182        let rest = msg
183            .strip_prefix("Audio Codec Compare ")
184            .or_else(|| msg.strip_prefix("Video Codec Compare "))
185            .unwrap_or(msg);
186        // Every shape opens with the bracketed remote offer. Stripping the `[`
187        // rather than slicing past it keeps a multi-byte first character from
188        // splitting mid-codepoint.
189        let rest = rest
190            .strip_prefix('[')
191            .ok_or(CodecTraceError::UnknownShape)?;
192        let token = |t: &str| CodecOffer::parse(media, t).map_err(CodecTraceError::BadToken);
193
194        if let Some(slash) = rest.find("]/[") {
195            return Ok(CodecTrace::Compared {
196                offered: token(&rest[..slash])?,
197                local: token(rest[slash + 3..].trim_end_matches(']'))?,
198            });
199        }
200
201        let end = rest.find(']').ok_or(CodecTraceError::UnknownShape)?;
202        let (codec, verdict) = (&rest[..end], &rest[end + 1..]);
203        if verdict.contains("was not saved") {
204            Ok(CodecTrace::Dropped(token(codec)?))
205        } else if verdict.contains("near-match") {
206            Ok(CodecTrace::NearMatched(token(codec)?))
207        } else if verdict.contains("is saved as a match") {
208            Ok(CodecTrace::Matched(token(codec)?))
209        } else {
210            Err(CodecTraceError::UnknownShape)
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn audio(token: &str) -> CodecOffer {
220        CodecOffer::parse(CodecMedia::Audio, token).expect("parsed")
221    }
222
223    #[test]
224    fn audio_six_field_form() {
225        let c = audio("opus:116:16000:20:0:1");
226        assert_eq!(c.name, "opus");
227        assert_eq!(c.payload_type, 116);
228        assert_eq!(c.clock_rate, Some(16000));
229        assert_eq!(c.ptime, Some(20));
230        assert_eq!(c.bitrate, Some(0));
231        assert_eq!(c.channels, Some(1));
232    }
233
234    #[test]
235    fn audio_seven_field_form_skips_the_extra_rate() {
236        // switch_core_media.c:5575 logs actual_samples_per_second after the
237        // codec rate; every other audio trace omits it.
238        let c = audio("PCMU:0:8000:8000:20:64000:1");
239        assert_eq!(c.clock_rate, Some(8000));
240        assert_eq!(c.ptime, Some(20));
241        assert_eq!(c.bitrate, Some(64000));
242        assert_eq!(c.channels, Some(1));
243    }
244
245    #[test]
246    fn video_two_field_form() {
247        let c = CodecOffer::parse(CodecMedia::Video, "H264:109").expect("parsed");
248        assert_eq!(c.name, "H264");
249        assert_eq!(c.payload_type, 109);
250        assert_eq!(c.clock_rate, None);
251        assert_eq!(c.channels, None);
252    }
253
254    #[test]
255    fn media_type_decides_the_arity() {
256        assert!(CodecOffer::parse(CodecMedia::Audio, "H264:109").is_err());
257        assert!(CodecOffer::parse(CodecMedia::Video, "opus:116:16000:20:0:1").is_err());
258    }
259
260    #[test]
261    fn malformed_tokens_do_not_parse_partially() {
262        assert_eq!(
263            CodecOffer::parse(CodecMedia::Audio, ""),
264            Err(CodecParseError::EmptyName)
265        );
266        assert_eq!(
267            CodecOffer::parse(CodecMedia::Video, "H264:notanumber"),
268            Err(CodecParseError::NotNumeric {
269                field: "payload type"
270            })
271        );
272        assert_eq!(
273            CodecOffer::parse(CodecMedia::Audio, "opus:116:sixteen:20:0:1"),
274            Err(CodecParseError::NotNumeric {
275                field: "clock rate"
276            })
277        );
278        assert!(matches!(
279            CodecOffer::parse(CodecMedia::Audio, "opus:116:16000"),
280            Err(CodecParseError::Arity { fields: 3, .. })
281        ));
282    }
283
284    #[test]
285    fn display_omits_absent_fields() {
286        assert_eq!(
287            audio("opus:116:16000:20:0:1").to_string(),
288            "opus:116 16000Hz 20ms 0b 1ch"
289        );
290        assert_eq!(
291            CodecOffer::parse(CodecMedia::Video, "H264:109")
292                .unwrap()
293                .to_string(),
294            "H264:109"
295        );
296    }
297}