use bon::Builder;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerFormat {
Mp4,
Fmp4,
MpegTs,
MpegAudio,
Adts,
Flac,
Wav,
Ogg,
Caf,
Mkv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioCodec {
AacLc,
AacHe,
AacHeV2,
Mp3,
Flac,
Vorbis,
Opus,
Alac,
Pcm,
Adpcm,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Builder)]
#[non_exhaustive]
pub struct MediaInfo {
pub channels: Option<u16>,
pub codec: Option<AudioCodec>,
pub container: Option<ContainerFormat>,
pub sample_rate: Option<u32>,
pub variant_index: Option<u32>,
}
impl MediaInfo {
#[must_use]
pub fn new(codec: Option<AudioCodec>, container: Option<ContainerFormat>) -> Self {
Self {
codec,
container,
channels: None,
sample_rate: None,
variant_index: None,
}
}
#[must_use]
pub fn parse_mime(mime: &str) -> Option<Self> {
let codec = AudioCodec::parse_mime(mime)?;
let container = match mime.to_lowercase().as_str() {
"audio/mp4" | "audio/x-m4a" => Some(ContainerFormat::Mp4),
"audio/aac" | "audio/aacp" => Some(ContainerFormat::Adts),
_ => ContainerFormat::try_from(codec).ok(),
};
Some(Self::new(Some(codec), container))
}
}
impl From<AudioCodec> for MediaInfo {
fn from(codec: AudioCodec) -> Self {
Self::new(Some(codec), ContainerFormat::try_from(codec).ok())
}
}
impl TryFrom<AudioCodec> for ContainerFormat {
type Error = AmbiguousContainer;
fn try_from(codec: AudioCodec) -> Result<Self, Self::Error> {
match codec {
AudioCodec::Mp3 => Ok(Self::MpegAudio),
AudioCodec::Pcm => Ok(Self::Wav),
AudioCodec::Flac => Ok(Self::Flac),
AudioCodec::Vorbis | AudioCodec::Opus => Ok(Self::Ogg),
AudioCodec::Alac => Ok(Self::Caf),
AudioCodec::AacLc | AudioCodec::AacHe | AudioCodec::AacHeV2 | AudioCodec::Adpcm => {
Err(AmbiguousContainer(codec))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("ambiguous container for codec: {0:?}")]
pub struct AmbiguousContainer(pub AudioCodec);
impl AudioCodec {
#[must_use]
pub fn encoder_priming_frames(codec: Self) -> u64 {
match codec {
Self::AacLc | Self::AacHe | Self::AacHeV2 => 1024,
Self::Mp3 => 576,
Self::Opus => 312,
Self::Flac | Self::Vorbis | Self::Alac | Self::Pcm | Self::Adpcm => 0,
}
}
#[must_use]
pub fn parse_hls_codec(codec: &str) -> Option<Self> {
let codec_lower = codec.to_lowercase();
if codec_lower.starts_with("mp4a.40.29") {
Some(Self::AacHeV2)
} else if codec_lower.starts_with("mp4a.40.34") {
Some(Self::Mp3)
} else if codec_lower.starts_with("mp4a.40.5") {
Some(Self::AacHe)
} else if codec_lower.starts_with("mp4a.40.2") {
Some(Self::AacLc)
} else if codec_lower.starts_with("mp4a.69") || codec_lower.starts_with("mp4a.6b") {
Some(Self::Mp3)
} else if codec_lower.starts_with("flac") || codec_lower.starts_with("fLaC") {
Some(Self::Flac)
} else if codec_lower.starts_with("vorbis") {
Some(Self::Vorbis)
} else if codec_lower.starts_with("opus") {
Some(Self::Opus)
} else if codec_lower.starts_with("alac") {
Some(Self::Alac)
} else {
None
}
}
#[must_use]
pub fn parse_mime(mime: &str) -> Option<Self> {
let m = mime.to_lowercase();
if m.contains("mp3") || m == "audio/mpeg" {
return Some(Self::Mp3);
}
if m.contains("aac") {
return Some(Self::AacLc);
}
if m.contains("flac") {
return Some(Self::Flac);
}
if m.contains("vorbis") {
return Some(Self::Vorbis);
}
if m.contains("opus") {
return Some(Self::Opus);
}
if m == "audio/ogg" {
return Some(Self::Vorbis);
}
if m == "audio/wav" || m == "audio/wave" || m == "audio/x-wav" {
return Some(Self::Pcm);
}
if m == "audio/mp4" || m == "audio/x-m4a" {
return Some(Self::AacLc);
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum CodecMagicError {
#[error("magic prefix needs at least 4 bytes, got {got}")]
TooShort {
got: usize,
},
#[error("magic prefix did not match any known codec")]
Unknown,
}
impl TryFrom<&[u8]> for AudioCodec {
type Error = CodecMagicError;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
match bytes {
b if b.len() < 4 => Err(CodecMagicError::TooShort { got: b.len() }),
[b'I', b'D', b'3', ..] => Ok(Self::Mp3),
[b'f', b'L', b'a', b'C', ..] => Ok(Self::Flac),
[b'O', b'g', b'g', b'S', ..] => Ok(Self::Vorbis),
[
b'R',
b'I',
b'F',
b'F',
_,
_,
_,
_,
b'W',
b'A',
b'V',
b'E',
..,
] => Ok(Self::Pcm),
[_, _, _, _, b'f', b't', b'y', b'p', ..] => Ok(Self::AacLc),
[0xFF, b1, ..] if (b1 & 0xE0) == 0xE0 => match (b1 >> 1) & 0b11 {
0b00 => Ok(Self::AacLc),
_ => Ok(Self::Mp3),
},
_ => Err(CodecMagicError::Unknown),
}
}
}
#[cfg(test)]
mod tests {
use kithara_test_utils::kithara;
use super::*;
#[kithara::test(wasm)]
#[case("mp4a.40.2", Some(AudioCodec::AacLc), "AAC-LC standard")]
#[case("MP4A.40.2", Some(AudioCodec::AacLc), "AAC-LC uppercase")]
#[case("mp4a.40.5", Some(AudioCodec::AacHe), "AAC-HE")]
#[case("mp4a.40.29", Some(AudioCodec::AacHeV2), "AAC-HE v2")]
#[case("mp4a.40.34", Some(AudioCodec::Mp3), "MP3 via mp4a.40.34")]
#[case("mp4a.69", Some(AudioCodec::Mp3), "MP3 via mp4a.69")]
#[case("mp4a.6B", Some(AudioCodec::Mp3), "MP3 via mp4a.6B uppercase")]
#[case("mp4a.6b", Some(AudioCodec::Mp3), "MP3 via mp4a.6b")]
#[case("flac", Some(AudioCodec::Flac), "FLAC lowercase")]
#[case("FLAC", Some(AudioCodec::Flac), "FLAC uppercase")]
#[case("fLaC", Some(AudioCodec::Flac), "FLAC mixed case")]
#[case("vorbis", Some(AudioCodec::Vorbis), "Vorbis")]
#[case("opus", Some(AudioCodec::Opus), "Opus")]
#[case("alac", Some(AudioCodec::Alac), "ALAC")]
#[case("unknown", None, "Unknown codec")]
#[case("", None, "Empty string")]
#[case("mp4a", None, "Incomplete codec string")]
fn test_hls_codec_parsing(
#[case] codec_str: &str,
#[case] expected: Option<AudioCodec>,
#[case] _description: &str,
) {
assert_eq!(AudioCodec::parse_hls_codec(codec_str), expected);
}
#[kithara::test]
fn test_media_info_default() {
let info = MediaInfo::default();
assert_eq!(info.container, None);
assert_eq!(info.codec, None);
assert_eq!(info.sample_rate, None);
assert_eq!(info.channels, None);
}
#[kithara::test(wasm)]
#[case(ContainerFormat::Fmp4)]
#[case(ContainerFormat::MpegTs)]
#[case(ContainerFormat::MpegAudio)]
#[case(ContainerFormat::Adts)]
#[case(ContainerFormat::Flac)]
#[case(ContainerFormat::Wav)]
#[case(ContainerFormat::Ogg)]
#[case(ContainerFormat::Caf)]
#[case(ContainerFormat::Mkv)]
fn test_media_info_with_container(#[case] container: ContainerFormat) {
let info = MediaInfo::builder().container(container).build();
assert_eq!(info.container, Some(container));
assert_eq!(info.codec, None);
assert_eq!(info.sample_rate, None);
assert_eq!(info.channels, None);
}
#[kithara::test(wasm)]
#[case(44100)]
#[case(48000)]
#[case(88200)]
#[case(96000)]
#[case(192000)]
fn test_media_info_with_sample_rate(#[case] sample_rate: u32) {
let info = MediaInfo::builder().sample_rate(sample_rate).build();
assert_eq!(info.container, None);
assert_eq!(info.codec, None);
assert_eq!(info.sample_rate, Some(sample_rate));
assert_eq!(info.channels, None);
}
#[kithara::test(wasm)]
#[case(1)]
#[case(2)]
#[case(6)]
#[case(8)]
fn test_media_info_with_channels(#[case] channels: u16) {
let info = MediaInfo::builder().channels(channels).build();
assert_eq!(info.container, None);
assert_eq!(info.codec, None);
assert_eq!(info.sample_rate, None);
assert_eq!(info.channels, Some(channels));
}
#[kithara::test]
fn test_media_info_builder_chain() {
let mut info = MediaInfo::builder()
.container(ContainerFormat::Fmp4)
.sample_rate(44100)
.channels(2)
.build();
info.codec = Some(AudioCodec::AacLc);
assert_eq!(info.container, Some(ContainerFormat::Fmp4));
assert_eq!(info.codec, Some(AudioCodec::AacLc));
assert_eq!(info.sample_rate, Some(44100));
assert_eq!(info.channels, Some(2));
}
#[kithara::test]
fn test_media_info_partial_builder() {
let mut info = MediaInfo::builder().sample_rate(48000).build();
info.codec = Some(AudioCodec::Mp3);
assert_eq!(info.container, None);
assert_eq!(info.codec, Some(AudioCodec::Mp3));
assert_eq!(info.sample_rate, Some(48000));
assert_eq!(info.channels, None);
}
#[kithara::test]
fn test_container_format_debug() {
let format = ContainerFormat::Fmp4;
let debug_str = format!("{:?}", format);
assert!(debug_str.contains("Fmp4"));
}
#[kithara::test]
fn test_audio_codec_debug() {
let codec = AudioCodec::AacLc;
let debug_str = format!("{:?}", codec);
assert!(debug_str.contains("AacLc"));
}
#[kithara::test]
fn test_media_info_clone() {
let mut info = MediaInfo::builder()
.container(ContainerFormat::Fmp4)
.build();
info.codec = Some(AudioCodec::AacLc);
let cloned = info.clone();
assert_eq!(info, cloned);
}
#[kithara::test]
fn test_media_info_partial_eq() {
let info1 = MediaInfo {
codec: Some(AudioCodec::AacLc),
..Default::default()
};
let info2 = MediaInfo {
codec: Some(AudioCodec::AacLc),
..Default::default()
};
let info3 = MediaInfo {
codec: Some(AudioCodec::Mp3),
..Default::default()
};
assert_eq!(info1, info2);
assert_ne!(info1, info3);
}
#[kithara::test]
#[case::id3v2(
b"ID3\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
AudioCodec::Mp3
)]
#[case::mpeg_sync_layer3(&[0xFF, 0xFB, 0x90, 0x44], AudioCodec::Mp3)]
#[case::aac_adts_sync(&[0xFF, 0xF1, 0x50, 0x80, 0x00, 0x1F, 0xFC], AudioCodec::AacLc)]
#[case::flac(b"fLaC\x00\x00\x00\x22", AudioCodec::Flac)]
#[case::ogg(b"OggS\x00\x02\x00\x00", AudioCodec::Vorbis)]
#[case::wav(b"RIFF\x24\x08\x00\x00WAVEfmt ", AudioCodec::Pcm)]
#[case::mp4(b"\x00\x00\x00\x20ftypisom", AudioCodec::AacLc)]
fn try_from_recognises_known_magic(#[case] bytes: &[u8], #[case] expected: AudioCodec) {
assert_eq!(AudioCodec::try_from(bytes), Ok(expected));
}
#[kithara::test]
fn try_from_rejects_short_buffer() {
assert_eq!(
AudioCodec::try_from(&b"ID"[..]),
Err(CodecMagicError::TooShort { got: 2 })
);
}
#[kithara::test]
#[case::random(&[0x00, 0x01, 0x02, 0x03])]
#[case::almost_riff_no_wave(b"RIFF\x00\x00\x00\x00XXXX____")]
#[case::sync_byte_alone(&[0xFE, 0xFB, 0x00, 0x00])]
fn try_from_unknown_magic_errors(#[case] bytes: &[u8]) {
assert_eq!(AudioCodec::try_from(bytes), Err(CodecMagicError::Unknown));
}
}