Skip to main content

kithara_stream/
media.rs

1use bon::Builder;
2
3/// Container format type.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ContainerFormat {
6    /// Standard MP4 (ISO/IEC 14496-12)
7    Mp4,
8    /// Fragmented MP4 (fMP4) - common for HLS
9    Fmp4,
10    /// MPEG Transport Stream
11    MpegTs,
12    /// MPEG Audio (MP3 without container)
13    MpegAudio,
14    /// AAC ADTS (raw AAC with ADTS framing)
15    Adts,
16    /// FLAC (native FLAC stream)
17    Flac,
18    /// RIFF WAVE
19    Wav,
20    /// Ogg container
21    Ogg,
22    /// CAF (Core Audio Format)
23    Caf,
24    /// Matroska/WebM
25    Mkv,
26}
27
28/// Audio codec type.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AudioCodec {
31    /// AAC Low Complexity (mp4a.40.2)
32    AacLc,
33    /// AAC High Efficiency (mp4a.40.5)
34    AacHe,
35    /// AAC HE v2 (mp4a.40.29)
36    AacHeV2,
37    /// MP3 (mp4a.40.34 or audio/mpeg)
38    Mp3,
39    /// FLAC
40    Flac,
41    /// Vorbis
42    Vorbis,
43    /// Opus
44    Opus,
45    /// ALAC (Apple Lossless)
46    Alac,
47    /// PCM
48    Pcm,
49    /// ADPCM
50    Adpcm,
51}
52
53/// Media format information.
54///
55/// This information can be derived from:
56/// - HLS playlist `CODECS` attribute (e.g., `mp4a.40.2`)
57/// - File extension
58/// - HTTP Content-Type header
59/// - Container metadata
60#[derive(Debug, Clone, Default, PartialEq, Eq, Builder)]
61#[non_exhaustive]
62pub struct MediaInfo {
63    /// Number of audio channels
64    pub channels: Option<u16>,
65    /// Audio codec
66    pub codec: Option<AudioCodec>,
67    /// Container format (fMP4, MPEG-TS, etc.)
68    pub container: Option<ContainerFormat>,
69    /// Sample rate in Hz
70    pub sample_rate: Option<u32>,
71    /// Variant index (for ABR streams).
72    /// Different variants have different init segments (ftyp/moov),
73    /// so decoder must be recreated when variant changes.
74    pub variant_index: Option<u32>,
75}
76
77impl MediaInfo {
78    /// Create `MediaInfo` with optional codec and container.
79    #[must_use]
80    pub fn new(codec: Option<AudioCodec>, container: Option<ContainerFormat>) -> Self {
81        Self {
82            codec,
83            container,
84            channels: None,
85            sample_rate: None,
86            variant_index: None,
87        }
88    }
89
90    /// Parse codec **and** container from an HTTP `Content-Type` value.
91    ///
92    /// Distinct from [`AudioCodec::parse_mime`], which returns the codec
93    /// only. Standalone HTTP file sources can lose container information
94    /// if the caller drops it on the floor; downstream Apple/Android
95    /// dispatch needs both codec and container to pick a backend.
96    #[must_use]
97    pub fn parse_mime(mime: &str) -> Option<Self> {
98        let codec = AudioCodec::parse_mime(mime)?;
99        let container = match mime.to_lowercase().as_str() {
100            "audio/mp4" | "audio/x-m4a" => Some(ContainerFormat::Mp4),
101            "audio/aac" | "audio/aacp" => Some(ContainerFormat::Adts),
102            _ => ContainerFormat::try_from(codec).ok(),
103        };
104        Some(Self::new(Some(codec), container))
105    }
106}
107
108/// Build `MediaInfo` from a codec alone, filling the container when it is
109/// implied by the codec for standalone (non-HLS) sources. AAC and Adpcm
110/// have ambiguous containers and leave `container = None`.
111impl From<AudioCodec> for MediaInfo {
112    fn from(codec: AudioCodec) -> Self {
113        Self::new(Some(codec), ContainerFormat::try_from(codec).ok())
114    }
115}
116
117/// The codec uniquely picks a container for standalone sources.
118/// Mp3→MpegAudio, Pcm→Wav, Flac→Flac, Vorbis/Opus→Ogg, Alac→Caf.
119/// AAC (ADTS vs Mp4) and Adpcm are ambiguous and fail.
120impl TryFrom<AudioCodec> for ContainerFormat {
121    type Error = AmbiguousContainer;
122
123    fn try_from(codec: AudioCodec) -> Result<Self, Self::Error> {
124        match codec {
125            AudioCodec::Mp3 => Ok(Self::MpegAudio),
126            AudioCodec::Pcm => Ok(Self::Wav),
127            AudioCodec::Flac => Ok(Self::Flac),
128            AudioCodec::Vorbis | AudioCodec::Opus => Ok(Self::Ogg),
129            AudioCodec::Alac => Ok(Self::Caf),
130            AudioCodec::AacLc | AudioCodec::AacHe | AudioCodec::AacHeV2 | AudioCodec::Adpcm => {
131                Err(AmbiguousContainer(codec))
132            }
133        }
134    }
135}
136
137/// Returned by `TryFrom<AudioCodec> for ContainerFormat` when the codec
138/// alone is not enough to determine the container (AAC, Adpcm).
139#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
140#[error("ambiguous container for codec: {0:?}")]
141pub struct AmbiguousContainer(pub AudioCodec);
142
143impl AudioCodec {
144    /// Encoder-side priming silence in PCM frames added by mainstream
145    /// encoders for `codec` when no container or encoder tag declares
146    /// an explicit count. Used as a fallback by the gapless pipeline
147    /// when probing yields no metadata.
148    ///
149    /// Does **not** include any decoder-side algorithmic delay — that
150    /// is per-backend (LAME-convention `mpa` decoders add 529 for MP3,
151    /// Apple's `AudioConverter` internally compensates and adds 0) and
152    /// lives on the `FrameCodec` trait in `kithara-decode`.
153    ///
154    /// Free-standing (`AudioCodec::encoder_priming_frames(codec)`)
155    /// rather than `codec.encoder_priming_frames()` so that the
156    /// `match codec { ... }` body does not pretend to be a
157    /// `From<AudioCodec>` conversion — `u64` here means "priming
158    /// frames", not the codec rewritten as an integer.
159    #[must_use]
160    pub fn encoder_priming_frames(codec: Self) -> u64 {
161        match codec {
162            Self::AacLc | Self::AacHe | Self::AacHeV2 => 1024,
163            Self::Mp3 => 576,
164            Self::Opus => 312,
165            Self::Flac | Self::Vorbis | Self::Alac | Self::Pcm | Self::Adpcm => 0,
166        }
167    }
168
169    /// Parse from HLS CODECS attribute value.
170    ///
171    /// Examples:
172    /// - `mp4a.40.2` -> `AacLc`
173    /// - `mp4a.40.5` -> `AacHe`
174    /// - `mp4a.40.29` -> `AacHeV2`
175    /// - `mp4a.40.34` -> `Mp3`
176    /// - `mp4a.69` or `mp4a.6B` -> `Mp3`
177    #[must_use]
178    pub fn parse_hls_codec(codec: &str) -> Option<Self> {
179        let codec_lower = codec.to_lowercase();
180
181        if codec_lower.starts_with("mp4a.40.29") {
182            Some(Self::AacHeV2)
183        } else if codec_lower.starts_with("mp4a.40.34") {
184            Some(Self::Mp3)
185        } else if codec_lower.starts_with("mp4a.40.5") {
186            Some(Self::AacHe)
187        } else if codec_lower.starts_with("mp4a.40.2") {
188            Some(Self::AacLc)
189        } else if codec_lower.starts_with("mp4a.69") || codec_lower.starts_with("mp4a.6b") {
190            Some(Self::Mp3)
191        } else if codec_lower.starts_with("flac") || codec_lower.starts_with("fLaC") {
192            Some(Self::Flac)
193        } else if codec_lower.starts_with("vorbis") {
194            Some(Self::Vorbis)
195        } else if codec_lower.starts_with("opus") {
196            Some(Self::Opus)
197        } else if codec_lower.starts_with("alac") {
198            Some(Self::Alac)
199        } else {
200            None
201        }
202    }
203
204    /// Parse codec from HTTP Content-Type header value.
205    ///
206    /// Examples:
207    /// - `audio/mpeg` -> `Mp3`
208    /// - `audio/aac` -> `AacLc`
209    /// - `audio/flac` -> `Flac`
210    #[must_use]
211    pub fn parse_mime(mime: &str) -> Option<Self> {
212        let m = mime.to_lowercase();
213        if m.contains("mp3") || m == "audio/mpeg" {
214            return Some(Self::Mp3);
215        }
216        if m.contains("aac") {
217            return Some(Self::AacLc);
218        }
219        if m.contains("flac") {
220            return Some(Self::Flac);
221        }
222        if m.contains("vorbis") {
223            return Some(Self::Vorbis);
224        }
225        if m.contains("opus") {
226            return Some(Self::Opus);
227        }
228        if m == "audio/ogg" {
229            return Some(Self::Vorbis);
230        }
231        if m == "audio/wav" || m == "audio/wave" || m == "audio/x-wav" {
232            return Some(Self::Pcm);
233        }
234        if m == "audio/mp4" || m == "audio/x-m4a" {
235            return Some(Self::AacLc);
236        }
237        None
238    }
239}
240
241/// Error returned by [`TryFrom<&[u8]> for AudioCodec`] when the magic
242/// prefix can't be classified.
243///
244/// Used on cache hits — when the original HTTP `Content-Type` header is
245/// no longer available and the URL path carries no extension hint
246/// (`streamhq?id=N`) — to recover the codec from the bytes that were
247/// already persisted on disk.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
249pub enum CodecMagicError {
250    /// Buffer is shorter than 4 bytes — not enough to hold any of the
251    /// known magic sequences.
252    #[error("magic prefix needs at least 4 bytes, got {got}")]
253    TooShort {
254        /// Length of the supplied buffer in bytes.
255        got: usize,
256    },
257    /// The first bytes did not match any codec we can identify by magic.
258    /// Callers should surface this as a probe failure rather than
259    /// guessing.
260    #[error("magic prefix did not match any known codec")]
261    Unknown,
262}
263
264impl TryFrom<&[u8]> for AudioCodec {
265    type Error = CodecMagicError;
266
267    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
268        match bytes {
269            b if b.len() < 4 => Err(CodecMagicError::TooShort { got: b.len() }),
270            [b'I', b'D', b'3', ..] => Ok(Self::Mp3),
271            [b'f', b'L', b'a', b'C', ..] => Ok(Self::Flac),
272            [b'O', b'g', b'g', b'S', ..] => Ok(Self::Vorbis),
273            [
274                b'R',
275                b'I',
276                b'F',
277                b'F',
278                _,
279                _,
280                _,
281                _,
282                b'W',
283                b'A',
284                b'V',
285                b'E',
286                ..,
287            ] => Ok(Self::Pcm),
288            [_, _, _, _, b'f', b't', b'y', b'p', ..] => Ok(Self::AacLc),
289            [0xFF, b1, ..] if (b1 & 0xE0) == 0xE0 => match (b1 >> 1) & 0b11 {
290                0b00 => Ok(Self::AacLc),
291                _ => Ok(Self::Mp3),
292            },
293            _ => Err(CodecMagicError::Unknown),
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use kithara_test_utils::kithara;
301
302    use super::*;
303
304    #[kithara::test(wasm)]
305    #[case("mp4a.40.2", Some(AudioCodec::AacLc), "AAC-LC standard")]
306    #[case("MP4A.40.2", Some(AudioCodec::AacLc), "AAC-LC uppercase")]
307    #[case("mp4a.40.5", Some(AudioCodec::AacHe), "AAC-HE")]
308    #[case("mp4a.40.29", Some(AudioCodec::AacHeV2), "AAC-HE v2")]
309    #[case("mp4a.40.34", Some(AudioCodec::Mp3), "MP3 via mp4a.40.34")]
310    #[case("mp4a.69", Some(AudioCodec::Mp3), "MP3 via mp4a.69")]
311    #[case("mp4a.6B", Some(AudioCodec::Mp3), "MP3 via mp4a.6B uppercase")]
312    #[case("mp4a.6b", Some(AudioCodec::Mp3), "MP3 via mp4a.6b")]
313    #[case("flac", Some(AudioCodec::Flac), "FLAC lowercase")]
314    #[case("FLAC", Some(AudioCodec::Flac), "FLAC uppercase")]
315    #[case("fLaC", Some(AudioCodec::Flac), "FLAC mixed case")]
316    #[case("vorbis", Some(AudioCodec::Vorbis), "Vorbis")]
317    #[case("opus", Some(AudioCodec::Opus), "Opus")]
318    #[case("alac", Some(AudioCodec::Alac), "ALAC")]
319    #[case("unknown", None, "Unknown codec")]
320    #[case("", None, "Empty string")]
321    #[case("mp4a", None, "Incomplete codec string")]
322    fn test_hls_codec_parsing(
323        #[case] codec_str: &str,
324        #[case] expected: Option<AudioCodec>,
325        #[case] _description: &str,
326    ) {
327        assert_eq!(AudioCodec::parse_hls_codec(codec_str), expected);
328    }
329
330    #[kithara::test]
331    fn test_media_info_default() {
332        let info = MediaInfo::default();
333        assert_eq!(info.container, None);
334        assert_eq!(info.codec, None);
335        assert_eq!(info.sample_rate, None);
336        assert_eq!(info.channels, None);
337    }
338
339    #[kithara::test(wasm)]
340    #[case(ContainerFormat::Fmp4)]
341    #[case(ContainerFormat::MpegTs)]
342    #[case(ContainerFormat::MpegAudio)]
343    #[case(ContainerFormat::Adts)]
344    #[case(ContainerFormat::Flac)]
345    #[case(ContainerFormat::Wav)]
346    #[case(ContainerFormat::Ogg)]
347    #[case(ContainerFormat::Caf)]
348    #[case(ContainerFormat::Mkv)]
349    fn test_media_info_with_container(#[case] container: ContainerFormat) {
350        let info = MediaInfo::builder().container(container).build();
351        assert_eq!(info.container, Some(container));
352        assert_eq!(info.codec, None);
353        assert_eq!(info.sample_rate, None);
354        assert_eq!(info.channels, None);
355    }
356
357    #[kithara::test(wasm)]
358    #[case(44100)]
359    #[case(48000)]
360    #[case(88200)]
361    #[case(96000)]
362    #[case(192000)]
363    fn test_media_info_with_sample_rate(#[case] sample_rate: u32) {
364        let info = MediaInfo::builder().sample_rate(sample_rate).build();
365        assert_eq!(info.container, None);
366        assert_eq!(info.codec, None);
367        assert_eq!(info.sample_rate, Some(sample_rate));
368        assert_eq!(info.channels, None);
369    }
370
371    #[kithara::test(wasm)]
372    #[case(1)]
373    #[case(2)]
374    #[case(6)]
375    #[case(8)]
376    fn test_media_info_with_channels(#[case] channels: u16) {
377        let info = MediaInfo::builder().channels(channels).build();
378        assert_eq!(info.container, None);
379        assert_eq!(info.codec, None);
380        assert_eq!(info.sample_rate, None);
381        assert_eq!(info.channels, Some(channels));
382    }
383
384    #[kithara::test]
385    fn test_media_info_builder_chain() {
386        let mut info = MediaInfo::builder()
387            .container(ContainerFormat::Fmp4)
388            .sample_rate(44100)
389            .channels(2)
390            .build();
391        info.codec = Some(AudioCodec::AacLc);
392
393        assert_eq!(info.container, Some(ContainerFormat::Fmp4));
394        assert_eq!(info.codec, Some(AudioCodec::AacLc));
395        assert_eq!(info.sample_rate, Some(44100));
396        assert_eq!(info.channels, Some(2));
397    }
398
399    #[kithara::test]
400    fn test_media_info_partial_builder() {
401        let mut info = MediaInfo::builder().sample_rate(48000).build();
402        info.codec = Some(AudioCodec::Mp3);
403
404        assert_eq!(info.container, None);
405        assert_eq!(info.codec, Some(AudioCodec::Mp3));
406        assert_eq!(info.sample_rate, Some(48000));
407        assert_eq!(info.channels, None);
408    }
409
410    #[kithara::test]
411    fn test_container_format_debug() {
412        let format = ContainerFormat::Fmp4;
413        let debug_str = format!("{:?}", format);
414        assert!(debug_str.contains("Fmp4"));
415    }
416
417    #[kithara::test]
418    fn test_audio_codec_debug() {
419        let codec = AudioCodec::AacLc;
420        let debug_str = format!("{:?}", codec);
421        assert!(debug_str.contains("AacLc"));
422    }
423
424    #[kithara::test]
425    fn test_media_info_clone() {
426        let mut info = MediaInfo::builder()
427            .container(ContainerFormat::Fmp4)
428            .build();
429        info.codec = Some(AudioCodec::AacLc);
430
431        let cloned = info.clone();
432        assert_eq!(info, cloned);
433    }
434
435    #[kithara::test]
436    fn test_media_info_partial_eq() {
437        let info1 = MediaInfo {
438            codec: Some(AudioCodec::AacLc),
439            ..Default::default()
440        };
441        let info2 = MediaInfo {
442            codec: Some(AudioCodec::AacLc),
443            ..Default::default()
444        };
445        let info3 = MediaInfo {
446            codec: Some(AudioCodec::Mp3),
447            ..Default::default()
448        };
449
450        assert_eq!(info1, info2);
451        assert_ne!(info1, info3);
452    }
453
454    #[kithara::test]
455    #[case::id3v2(
456        b"ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
457        AudioCodec::Mp3
458    )]
459    #[case::mpeg_sync_layer3(&[0xFF, 0xFB, 0x90, 0x44], AudioCodec::Mp3)]
460    #[case::aac_adts_sync(&[0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], AudioCodec::AacLc)]
461    #[case::flac(b"fLaC\x00\x00\x00\x22", AudioCodec::Flac)]
462    #[case::ogg(b"OggS\x00\x02\x00\x00", AudioCodec::Vorbis)]
463    #[case::wav(b"RIFF\x24\x08\x00\x00WAVEfmt ", AudioCodec::Pcm)]
464    #[case::mp4(b"\x00\x00\x00\x20ftypisom", AudioCodec::AacLc)]
465    fn try_from_recognises_known_magic(#[case] bytes: &[u8], #[case] expected: AudioCodec) {
466        assert_eq!(AudioCodec::try_from(bytes), Ok(expected));
467    }
468
469    #[kithara::test]
470    fn try_from_rejects_short_buffer() {
471        assert_eq!(
472            AudioCodec::try_from(&b"ID"[..]),
473            Err(CodecMagicError::TooShort { got: 2 })
474        );
475    }
476
477    #[kithara::test]
478    #[case::random(&[0x00, 0x01, 0x02, 0x03])]
479    #[case::almost_riff_no_wave(b"RIFF\x00\x00\x00\x00XXXX____")]
480    #[case::sync_byte_alone(&[0xFE, 0xFB, 0x00, 0x00])]
481    fn try_from_unknown_magic_errors(#[case] bytes: &[u8]) {
482        assert_eq!(AudioCodec::try_from(bytes), Err(CodecMagicError::Unknown));
483    }
484}