From 89978a9cceae1c20498dacf1ed6b217fbbdf68f9 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 13 Aug 2026 01:19:26 -0700 Subject: [PATCH] Add test MP3 builder --- src/audio.rs | 12 +- src/lib.rs | 5 +- src/mp3_builder.rs | 129 +++++++++++++++++ src/mp3_decoder.rs | 336 +++++++++++++++++++++++++++------------------ src/test.rs | 40 ------ 5 files changed, 342 insertions(+), 180 deletions(-) create mode 100644 src/mp3_builder.rs diff --git a/src/audio.rs b/src/audio.rs index f8a35746..4b6e1981 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -639,10 +639,14 @@ mod tests { std::fs::write( root.join("foo.mp3"), - mp3( - &["TALB=qux", "TIT2=bar", "TPE1=baz", "TPOS=1/2", "TRCK=3/4"], - 2, - ), + Mp3Builder::new() + .tag("TALB", "qux") + .tag("TIT2", "bar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .tag("TRCK", "3/4") + .frames(2) + .build(), ) .unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 57d832f4..d463d8bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -246,10 +246,11 @@ pub use self::{ #[cfg(test)] use { jpeg_builder::JpegBuilder, + mp3_builder::Mp3Builder, png_builder::PngBuilder, std::assert_matches, tempfile::TempDir, - test::{assert_cbor, assert_cbor_eq, assert_encoding, exif, flac, mp3, mp3_frame, tempdir}, + test::{assert_cbor, assert_cbor_eq, assert_encoding, exif, flac, tempdir}, unindent::unindent, webm_builder::WebmBuilder, }; @@ -421,6 +422,8 @@ mod derive; #[cfg(test)] mod jpeg_builder; #[cfg(test)] +mod mp3_builder; +#[cfg(test)] mod png_builder; #[cfg(test)] mod test; diff --git a/src/mp3_builder.rs b/src/mp3_builder.rs new file mode 100644 index 00000000..009c8d5c --- /dev/null +++ b/src/mp3_builder.rs @@ -0,0 +1,129 @@ +pub(crate) struct Mp3Builder { + frames: Vec>, + id3v1: bool, + id3v2: Option>, + trailing: Vec, + truncate: Option, +} + +impl Mp3Builder { + pub(crate) fn build(self) -> Vec { + let mut bytes = Vec::new(); + + if let Some(tags) = self.id3v2 { + let mut body = Vec::new(); + + for (id, value) in tags { + body.extend_from_slice(id.as_bytes()); + body.extend_from_slice(&Self::syncsafe(value.len() + 1)); + body.extend_from_slice(&[0; 2]); + body.push(3); + body.extend_from_slice(value.as_bytes()); + } + + bytes.extend_from_slice(b"ID3"); + bytes.extend_from_slice(&[4, 0, 0]); + bytes.extend_from_slice(&Self::syncsafe(body.len())); + bytes.extend(body); + } + + bytes.extend(self.frames.concat()); + + if self.id3v1 { + let mut tag = b"TAG".to_vec(); + tag.resize(128, 0); + bytes.extend(tag); + } + + bytes.extend(self.trailing); + + if let Some(len) = self.truncate { + bytes.truncate(len); + } + + bytes + } + + #[must_use] + pub(crate) fn frame(mut self, header: [u8; 4], size: usize) -> Self { + let mut bytes = header.to_vec(); + bytes.resize(size, 0); + self.frames.push(bytes); + self + } + + #[must_use] + pub(crate) fn frames(mut self, count: u32) -> Self { + for _ in 0..count { + self.frames.push(Self::standard()); + } + self + } + + #[must_use] + pub(crate) fn id3v1(mut self) -> Self { + self.id3v1 = true; + self + } + + #[must_use] + pub(crate) fn id3v2(mut self) -> Self { + self.id3v2.get_or_insert_default(); + self + } + + pub(crate) fn new() -> Self { + Self { + frames: Vec::new(), + id3v1: false, + id3v2: None, + trailing: Vec::new(), + truncate: None, + } + } + + fn standard() -> Vec { + let mut bytes = vec![0xFF, 0xFB, 0x90, 0x00]; + bytes.resize(417, 0); + bytes + } + + fn syncsafe(n: usize) -> [u8; 4] { + let n = u32::try_from(n).unwrap(); + [ + u8::try_from((n >> 21) & 0x7F).unwrap(), + u8::try_from((n >> 14) & 0x7F).unwrap(), + u8::try_from((n >> 7) & 0x7F).unwrap(), + u8::try_from(n & 0x7F).unwrap(), + ] + } + + #[must_use] + pub(crate) fn tag(mut self, id: &str, value: &str) -> Self { + self + .id3v2 + .get_or_insert_default() + .push((id.into(), value.into())); + self + } + + #[must_use] + pub(crate) fn trailing(mut self, trailing: &[u8]) -> Self { + self.trailing.extend_from_slice(trailing); + self + } + + #[must_use] + pub(crate) fn truncate(mut self, len: usize) -> Self { + self.truncate = Some(len); + self + } + + #[must_use] + pub(crate) fn xing(mut self) -> Self { + let mut bytes = Self::standard(); + bytes[36..40].copy_from_slice(b"Xing"); + self.frames.push(bytes); + self + } +} diff --git a/src/mp3_decoder.rs b/src/mp3_decoder.rs index 12866d09..6ee864a0 100644 --- a/src/mp3_decoder.rs +++ b/src/mp3_decoder.rs @@ -270,210 +270,272 @@ mod tests { use super::*; #[test] - fn decode() { - #[track_caller] - fn case(data: &[Vec], expected: Result) { - assert_eq!(Mp3Decoder::decode(&data.concat()), expected); - } - - fn frame(header: [u8; 4], size: usize) -> Vec { - let mut bytes = header.to_vec(); - bytes.resize(size, 0); - bytes - } - - fn properties(channels: u64, sample_rate: u64, samples: u64, size: u64) -> AudioProperties { - AudioProperties { - channels, - sample_rate, - samples, - size, - } + fn metadata_err() { + fn err(builder: Mp3Builder) -> AudioError { + Mp3Decoder::metadata(&builder.build()).unwrap_err() } - fn xing() -> Vec { - let mut bytes = mp3_frame(); - bytes[36..40].copy_from_slice(b"Xing"); - bytes - } + assert_matches!( + err(Mp3Builder::new().trailing(b"foo")), + AudioError::Mp3TagMissing, + ); - let id3v1 = { - let mut bytes = b"TAG".to_vec(); - bytes.resize(128, 0); - bytes - }; + assert_matches!( + err(Mp3Builder::new().id3v2().frames(1)), + AudioError::TagMissing { tag: "TALB" }, + ); - case( - &[mp3_frame(), mp3_frame()], - Ok(properties(2, 44100, 2304, 834)), + assert_matches!( + err(Mp3Builder::new().tag("TALB", "qux").frames(1)), + AudioError::TagMissing { tag: "TPE1" }, ); - case( - &[xing(), mp3_frame(), mp3_frame()], - Ok(properties(2, 44100, 2304, 834)), + assert_matches!( + err(Mp3Builder::new().tag("TALB", "qux\0quux").frames(1)), + AudioError::TagMultiple { tag: "TALB" }, ); - case( - &[frame([0xFF, 0xFB, 0x92, 0x00], 418), mp3_frame()], - Ok(properties(2, 44100, 2304, 835)), + assert_matches!( + err(Mp3Builder::new().tag("TALB", "").frames(1)), + AudioError::TagEmpty { tag: "TALB" }, ); - case( - &[frame([0xFF, 0xFB, 0x90, 0xC0], 417)], - Ok(properties(1, 44100, 1152, 417)), + assert_matches!( + err( + Mp3Builder::new() + .tag("TALB", "qux") + .tag("TIT2", "foo\tbar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .frames(1) + ), + AudioError::TagInvalid { + source: TextError::Control { character: '\t' }, + tag: "TIT2", + }, ); - case( - &[frame([0xFF, 0xF3, 0x90, 0x00], 261)], - Ok(properties(2, 22050, 576, 261)), + assert_matches!( + err( + Mp3Builder::new() + .tag("TALB", "qux") + .tag("TPE1", "baz") + .tag("TPOS", "1") + .frames(1) + ), + AudioError::TagPair { tag: "TPOS" }, ); - case( - &[frame([0xFF, 0xE3, 0x90, 0x00], 522)], - Ok(properties(2, 11025, 576, 522)), + assert_matches!( + err( + Mp3Builder::new() + .tag("TALB", "qux") + .tag("TIT2", "bar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .tag("TRCK", "03/12") + .frames(1) + ), + AudioError::TagInteger { + source: NumberError::Invalid { .. }, + tag: "TRCK", + }, ); + } - case(&[], Err(Mp3Error::Empty)); + #[test] + fn properties_err() { + #[track_caller] + fn case(builder: Mp3Builder, expected: Mp3Error) { + let mp3 = builder + .tag("TALB", "qux") + .tag("TIT2", "bar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .tag("TRCK", "3/4") + .build(); + + match Mp3Decoder::metadata(&mp3).unwrap_err() { + AudioError::Mp3Decode { source } => assert_eq!(source, expected), + err => panic!("unexpected error: {err}"), + } + } - case(&[xing()], Err(Mp3Error::Empty)); + case(Mp3Builder::new(), Mp3Error::Empty); - case(&[mp3_frame(), id3v1], Err(Mp3Error::Sync { offset: 417 })); + case(Mp3Builder::new().xing(), Mp3Error::Empty); - case(&[b"foobar".to_vec()], Err(Mp3Error::Sync { offset: 0 })); + case( + Mp3Builder::new().frames(1).id3v1(), + Mp3Error::Sync { offset: 417 }, + ); case( - &[mp3_frame(), b"foobar".to_vec()], - Err(Mp3Error::Sync { offset: 417 }), + Mp3Builder::new().trailing(b"foobar"), + Mp3Error::Sync { offset: 0 }, ); - case(&[mp3_frame()[..100].to_vec()], Err(Mp3Error::Truncated)); + case( + Mp3Builder::new().frames(1).trailing(b"foobar"), + Mp3Error::Sync { offset: 417 }, + ); - case(&[mp3_frame(), b"bar".to_vec()], Err(Mp3Error::Truncated)); + case( + Mp3Builder::new().frames(1).truncate(180), + Mp3Error::Truncated, + ); case( - &[frame([0xFF, 0xEB, 0x90, 0x00], 417)], - Err(Mp3Error::Version), + Mp3Builder::new().frames(1).trailing(b"bar"), + Mp3Error::Truncated, ); case( - &[frame([0xFF, 0xF9, 0x90, 0x00], 417)], - Err(Mp3Error::LayerInvalid), + Mp3Builder::new().frame([0xFF, 0xEB, 0x90, 0x00], 417), + Mp3Error::Version, ); case( - &[frame([0xFF, 0xFD, 0x90, 0x00], 417)], - Err(Mp3Error::LayerUnsupported { layer: 2 }), + Mp3Builder::new().frame([0xFF, 0xF9, 0x90, 0x00], 417), + Mp3Error::LayerInvalid, ); case( - &[frame([0xFF, 0xFF, 0x90, 0x00], 417)], - Err(Mp3Error::LayerUnsupported { layer: 1 }), + Mp3Builder::new().frame([0xFF, 0xFD, 0x90, 0x00], 417), + Mp3Error::LayerUnsupported { layer: 2 }, ); case( - &[frame([0xFF, 0xFB, 0x00, 0x00], 417)], - Err(Mp3Error::Bitrate { index: 0 }), + Mp3Builder::new().frame([0xFF, 0xFF, 0x90, 0x00], 417), + Mp3Error::LayerUnsupported { layer: 1 }, ); case( - &[frame([0xFF, 0xFB, 0xF0, 0x00], 417)], - Err(Mp3Error::Bitrate { index: 15 }), + Mp3Builder::new().frame([0xFF, 0xFB, 0x00, 0x00], 417), + Mp3Error::Bitrate { index: 0 }, ); case( - &[frame([0xFF, 0xFB, 0x9C, 0x00], 417)], - Err(Mp3Error::SampleRate), + Mp3Builder::new().frame([0xFF, 0xFB, 0xF0, 0x00], 417), + Mp3Error::Bitrate { index: 15 }, ); case( - &[mp3_frame(), frame([0xFF, 0xFB, 0x90, 0xC0], 417)], - Err(Mp3Error::ChannelsMismatch { + Mp3Builder::new().frame([0xFF, 0xFB, 0x9C, 0x00], 417), + Mp3Error::SampleRate, + ); + + case( + Mp3Builder::new() + .frames(1) + .frame([0xFF, 0xFB, 0x90, 0xC0], 417), + Mp3Error::ChannelsMismatch { actual: 1, expected: 2, - }), + }, ); case( - &[mp3_frame(), frame([0xFF, 0xFB, 0x94, 0x00], 384)], - Err(Mp3Error::SampleRateMismatch { + Mp3Builder::new() + .frames(1) + .frame([0xFF, 0xFB, 0x94, 0x00], 384), + Mp3Error::SampleRateMismatch { actual: 48000, expected: 44100, - }), + }, ); } #[test] - fn metadata_err() { - fn err(bytes: &[u8]) -> AudioError { - Mp3Decoder::metadata(bytes).unwrap_err() + fn properties_ok() { + #[track_caller] + fn case(builder: Mp3Builder, expected: AudioProperties) { + let mp3 = builder + .tag("TALB", "qux") + .tag("TIT2", "bar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .tag("TRCK", "3/4") + .build(); + + let AudioMetadata { + channels, + sample_rate, + samples, + size, + .. + } = Mp3Decoder::metadata(&mp3).unwrap(); + + assert_eq!( + AudioProperties { + channels, + sample_rate, + samples, + size, + }, + expected, + ); } - assert_matches!(err(b"foo"), AudioError::Mp3TagMissing); - - assert_matches!(err(&mp3(&[], 1)), AudioError::TagMissing { tag: "TALB" }); - - assert_matches!( - err(&mp3(&["TALB=qux"], 1)), - AudioError::TagMissing { tag: "TPE1" }, - ); - - assert_matches!( - err(&mp3(&["TALB=qux\0quux"], 1)), - AudioError::TagMultiple { tag: "TALB" }, - ); - - assert_matches!( - err(&mp3(&["TALB="], 1)), - AudioError::TagEmpty { tag: "TALB" }, - ); - - assert_matches!( - err(&mp3( - &["TALB=qux", "TIT2=foo\tbar", "TPE1=baz", "TPOS=1/2"], - 1 - )), - AudioError::TagInvalid { - source: TextError::Control { character: '\t' }, - tag: "TIT2", + case( + Mp3Builder::new().frames(2), + AudioProperties { + channels: 2, + sample_rate: 44100, + samples: 2304, + size: 834, }, ); - assert_matches!( - err(&mp3(&["TALB=qux", "TPE1=baz", "TPOS=1"], 1)), - AudioError::TagPair { tag: "TPOS" }, + case( + Mp3Builder::new().xing().frames(2), + AudioProperties { + channels: 2, + sample_rate: 44100, + samples: 2304, + size: 834, + }, ); - assert_matches!( - err(&mp3( - &["TALB=qux", "TIT2=bar", "TPE1=baz", "TPOS=1/2", "TRCK=03/12"], - 1, - )), - AudioError::TagInteger { - source: NumberError::Invalid { .. }, - tag: "TRCK", + case( + Mp3Builder::new() + .frame([0xFF, 0xFB, 0x92, 0x00], 418) + .frames(1), + AudioProperties { + channels: 2, + sample_rate: 44100, + samples: 2304, + size: 835, }, ); - assert_matches!( - err(&mp3( - &["TALB=qux", "TIT2=bar", "TPE1=baz", "TPOS=1/2", "TRCK=3/4"], - 0, - )), - AudioError::Mp3Decode { - source: Mp3Error::Empty, + case( + Mp3Builder::new().frame([0xFF, 0xFB, 0x90, 0xC0], 417), + AudioProperties { + channels: 1, + sample_rate: 44100, + samples: 1152, + size: 417, }, ); - let mut bytes = mp3( - &["TALB=qux", "TIT2=bar", "TPE1=baz", "TPOS=1/2", "TRCK=3/4"], - 0, + case( + Mp3Builder::new().frame([0xFF, 0xF3, 0x90, 0x00], 261), + AudioProperties { + channels: 2, + sample_rate: 22050, + samples: 576, + size: 261, + }, ); - bytes.extend_from_slice(b"foobar"); - assert_matches!( - err(&bytes), - AudioError::Mp3Decode { - source: Mp3Error::Sync { offset: 0 }, + case( + Mp3Builder::new().frame([0xFF, 0xE3, 0x90, 0x00], 522), + AudioProperties { + channels: 2, + sample_rate: 11025, + samples: 576, + size: 522, }, ); } @@ -486,10 +548,14 @@ mod tests { std::fs::write( &path, - mp3( - &["TALB=qux", "TIT2=bar", "TPE1=baz", "TPOS=1/2", "TRCK=3/4"], - 2, - ), + Mp3Builder::new() + .tag("TALB", "qux") + .tag("TIT2", "bar") + .tag("TPE1", "baz") + .tag("TPOS", "1/2") + .tag("TRCK", "3/4") + .frames(2) + .build(), ) .unwrap(); diff --git a/src/test.rs b/src/test.rs index dc7c19c5..6fd7c3c6 100644 --- a/src/test.rs +++ b/src/test.rs @@ -132,46 +132,6 @@ pub(crate) fn flac(comments: &[&str], samples: u32) -> Vec { bytes } -pub(crate) fn mp3(tags: &[&str], frames: u32) -> Vec { - fn syncsafe(n: usize) -> [u8; 4] { - let n = u32::try_from(n).unwrap(); - [ - u8::try_from((n >> 21) & 0x7F).unwrap(), - u8::try_from((n >> 14) & 0x7F).unwrap(), - u8::try_from((n >> 7) & 0x7F).unwrap(), - u8::try_from(n & 0x7F).unwrap(), - ] - } - - let mut body = Vec::new(); - - for tag in tags { - let (id, value) = tag.split_once('=').unwrap(); - body.extend_from_slice(id.as_bytes()); - body.extend_from_slice(&syncsafe(value.len() + 1)); - body.extend_from_slice(&[0; 2]); - body.push(3); - body.extend_from_slice(value.as_bytes()); - } - - let mut bytes = b"ID3".to_vec(); - bytes.extend_from_slice(&[4, 0, 0]); - bytes.extend_from_slice(&syncsafe(body.len())); - bytes.extend(body); - - for _ in 0..frames { - bytes.extend_from_slice(&mp3_frame()); - } - - bytes -} - -pub(crate) fn mp3_frame() -> Vec { - let mut bytes = vec![0xFF, 0xFB, 0x90, 0x00]; - bytes.resize(417, 0); - bytes -} - pub(crate) fn tempdir() -> (TempDir, Utf8PathBuf) { let tempdir = tempfile::Builder::new() .prefix("filepack-test-tempdir")