1use bon::Builder;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ContainerFormat {
6 Mp4,
8 Fmp4,
10 MpegTs,
12 MpegAudio,
14 Adts,
16 Flac,
18 Wav,
20 Ogg,
22 Caf,
24 Mkv,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AudioCodec {
31 AacLc,
33 AacHe,
35 AacHeV2,
37 Mp3,
39 Flac,
41 Vorbis,
43 Opus,
45 Alac,
47 Pcm,
49 Adpcm,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Builder)]
61#[non_exhaustive]
62pub struct MediaInfo {
63 pub channels: Option<u16>,
65 pub codec: Option<AudioCodec>,
67 pub container: Option<ContainerFormat>,
69 pub sample_rate: Option<u32>,
71 pub variant_index: Option<u32>,
75}
76
77impl MediaInfo {
78 #[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 #[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
108impl From<AudioCodec> for MediaInfo {
112 fn from(codec: AudioCodec) -> Self {
113 Self::new(Some(codec), ContainerFormat::try_from(codec).ok())
114 }
115}
116
117impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
140#[error("ambiguous container for codec: {0:?}")]
141pub struct AmbiguousContainer(pub AudioCodec);
142
143impl AudioCodec {
144 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
249pub enum CodecMagicError {
250 #[error("magic prefix needs at least 4 bytes, got {got}")]
253 TooShort {
254 got: usize,
256 },
257 #[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}