From 0e60ef3b0a49e89538c3c867eec8e490798566a4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 14:24:44 -0500 Subject: [PATCH] craft-audio: extension and media-type tables, linear WAV chunk scan, SoundCraft's later tests (#824) DECODE_EXTENSIONS lists the file extensions of the formats decode reads. EncodeFormat gains ALL, extension() and mime_type(). SoundCraft kept these tables in its own FileFormat type; they are format knowledge, so they live here. A WAV with many empty data chunks parsed in quadratic time: whether an empty data chunk is followed by chunks alone was decided for every one of them, by walking the rest of the file. Only the first data chunk is used, so it is decided once. Tests SoundCraft added to audio-io after craft-audio was cut from it: Matroska element sizes beyond the file, an AIFF channel count with the sign bit set, probe and decode agreeing on the length of AAC in MP4, and one small file per decodable extension (tests/fixtures). --- ATTRIBUTION.md | 1 + crates/audio/README.md | 10 ++- crates/audio/src/lib.rs | 28 ++++++- crates/audio/src/read/mod.rs | 19 +++++ crates/audio/src/read/wav.rs | 5 +- crates/audio/tests/audio/decode_extensions.rs | 72 +++++++++++++++++ crates/audio/tests/audio/main.rs | 2 + crates/audio/tests/audio/malformed.rs | 74 ++++++++++++++++++ crates/audio/tests/audio/mp4_length.rs | 63 +++++++++++++++ crates/audio/tests/audio/wav_empty_data.rs | 18 +++++ crates/audio/tests/fixtures/README.md | 27 +++++++ crates/audio/tests/fixtures/silence.mp1 | Bin 0 -> 192 bytes crates/audio/tests/fixtures/tone.aac | Bin 0 -> 711 bytes crates/audio/tests/fixtures/tone.ac3 | Bin 0 -> 1024 bytes crates/audio/tests/fixtures/tone.aifc | Bin 0 -> 1672 bytes crates/audio/tests/fixtures/tone.caf | Bin 0 -> 1729 bytes crates/audio/tests/fixtures/tone.eac3 | Bin 0 -> 1024 bytes crates/audio/tests/fixtures/tone.m4a | Bin 0 -> 1511 bytes crates/audio/tests/fixtures/tone.mka | Bin 0 -> 2121 bytes crates/audio/tests/fixtures/tone.mkv | Bin 0 -> 2200 bytes crates/audio/tests/fixtures/tone.mov | Bin 0 -> 2253 bytes crates/audio/tests/fixtures/tone.mp2 | Bin 0 -> 576 bytes crates/audio/tests/fixtures/tone.mp3 | Bin 0 -> 836 bytes crates/audio/tests/fixtures/tone.ogg | Bin 0 -> 2855 bytes crates/audio/tests/fixtures/tone.opus | Bin 0 -> 469 bytes crates/audio/tests/fixtures/tone.webm | Bin 0 -> 3322 bytes 26 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 crates/audio/tests/audio/decode_extensions.rs create mode 100644 crates/audio/tests/audio/mp4_length.rs create mode 100644 crates/audio/tests/fixtures/README.md create mode 100644 crates/audio/tests/fixtures/silence.mp1 create mode 100644 crates/audio/tests/fixtures/tone.aac create mode 100644 crates/audio/tests/fixtures/tone.ac3 create mode 100644 crates/audio/tests/fixtures/tone.aifc create mode 100644 crates/audio/tests/fixtures/tone.caf create mode 100644 crates/audio/tests/fixtures/tone.eac3 create mode 100644 crates/audio/tests/fixtures/tone.m4a create mode 100644 crates/audio/tests/fixtures/tone.mka create mode 100644 crates/audio/tests/fixtures/tone.mkv create mode 100644 crates/audio/tests/fixtures/tone.mov create mode 100644 crates/audio/tests/fixtures/tone.mp2 create mode 100644 crates/audio/tests/fixtures/tone.mp3 create mode 100644 crates/audio/tests/fixtures/tone.ogg create mode 100644 crates/audio/tests/fixtures/tone.opus create mode 100644 crates/audio/tests/fixtures/tone.webm diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dfe5e003f..733696411 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -78,3 +78,4 @@ and the exact transfer revision are recorded in each vendored crate's SOURCE.jso | `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/pdfcraft/blob/a2ec27b4775f53c40cad3456936d9406611e3a8d/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | | `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/pdfcraft/blob/7437bde378f43856eada40af25b983812e615e8a/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | | `crates/audio/src/ac3/golden_frames.bin` | FilmCraft contributors (original work: 12 AC-3 and E-AC-3 syncframes that ffmpeg's `ac3` and `eac3` encoders made from tones and seeded noise synthesised by ffmpeg's lavfi generators; no third-party or recorded audio) | [FilmCraft `crates/ac3`](https://github.com/storytold/filmcraft/tree/1c9c2e82/crates/ac3/src) | MIT OR Apache-2.0 | +| `crates/audio/tests/fixtures/` | SoundCraft and craft-libs contributors (original work: 0.1 s test tones from ffmpeg's `sine` generator and hand-built silent MPEG frames; no third-party or recorded audio; commands in `crates/audio/tests/fixtures/README.md`) | [SoundCraft `crates/audio-io/tests/fixtures`](https://github.com/storytold/soundcraft/tree/e86d09a/crates/audio-io/tests/fixtures); `tone.mov`, `tone.mka`, `tone.opus`, `tone.ac3` and `tone.eac3` made in this repository | MIT OR Apache-2.0 | diff --git a/crates/audio/README.md b/crates/audio/README.md index 3f321821d..7cfb3471f 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -132,7 +132,8 @@ if let Some(second) = list.get(1) { A track index the file does not have is `Unsupported`. `DecodeOptions::with_max_samples` sets another size limit. `tracks_of` lists the tracks of a file read from a `ByteSource`; -`detect_container` names a file's container from its first bytes. +`detect_container` names a file's container from its first bytes. `DECODE_EXTENSIONS` lists the +file extensions of the formats `decode` reads, for a file picker's filter. ### Stream and seek @@ -224,7 +225,8 @@ enc.finish()?; `EncodeOptions::default()` is 24-bit WAV. `with_bwf` adds a BWF `bext` chunk and `with_force_rf64` writes RF64 below 4 GiB; both apply to WAV only. `encode_format_for("aiff")` maps an extension to an `EncodeFormat`, and `ENCODE_EXTENSIONS` lists the extensions the enabled -features can write. `write` refuses a block without one slice per channel, or with a slice +features can write. `EncodeFormat::ALL` lists the formats; `extension()` is the extension a new +file gets and `mime_type()` its media type. `write` refuses a block without one slice per channel, or with a slice shorter than `frames`, and writes nothing. After a write fails for another reason, every later `write` and `finish` returns an error. @@ -563,6 +565,10 @@ The codec modules' public items keep their names, so apps can re-export them - `AudioInfo` has `container`, `codec`, `layout` and `damaged` instead of SoundCraft's `format`. `detect_container` replaces `detect_format`. `probe` reports the layout instead of `wav_channel_mask`. +- SoundCraft's `FileFormat` tables are `DECODE_EXTENSIONS` (was `readable_extensions`), + `ENCODE_EXTENSIONS` and `encode_format_for` (was `from_write_extension`), and + `EncodeFormat::ALL`, `extension()` and `mime_type()` (were `WRITE`, `default_extension` and + `mime_type`). `aifc` is a writable name here. - `EncodeOptions` is built with `EncodeOptions::new(format)` and `with_*` methods. It carries the speaker layout and RF64, which replace `encode_with_layout`, `encode_wav_rf64` and `FileEncoder::new`'s layout argument. `BwfInfo` and `aac::EncoderConfig` are built the same way diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index cac8f0291..363297e10 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -84,7 +84,10 @@ pub use layout::{ChannelLayout, Speaker}; #[cfg(feature = "decode-native")] pub use read::SharedSource; #[cfg(feature = "decode-native")] -pub use read::{DEFAULT_MAX_SAMPLES, DecodeOptions, Decoder, TrackInfo, decode, decode_with, detect_container, probe, probe_with, tracks, tracks_of}; +pub use read::{ + DECODE_EXTENSIONS, DEFAULT_MAX_SAMPLES, DecodeOptions, Decoder, TrackInfo, decode, decode_with, detect_container, probe, probe_with, tracks, + tracks_of, +}; #[cfg(any(feature = "aac-decode", feature = "alac-decode", feature = "opus-decode", feature = "ac3-decode", feature = "mpa-decode"))] pub use track::{LOOKAHEAD_PACKETS, TrackDecoder}; #[cfg(feature = "pcm-encode")] @@ -202,6 +205,29 @@ pub enum EncodeFormat { } impl EncodeFormat { + /// Every format, in the order menus list them. Whether [`encode`] writes one depends on its + /// feature. + pub const ALL: &[EncodeFormat] = &[EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac]; + + /// The extension a new file of this format gets, lower case and without the dot: `"wav"`, + /// `"aif"`, `"flac"`. [`encode_format_for`] maps it back. + pub fn extension(self) -> &'static str { + match self { + EncodeFormat::Wav => "wav", + EncodeFormat::Aiff => "aif", + EncodeFormat::Flac => "flac", + } + } + + /// The media type a file of this format is served or downloaded as. + pub fn mime_type(self) -> &'static str { + match self { + EncodeFormat::Wav => "audio/wav", + EncodeFormat::Aiff => "audio/aiff", + EncodeFormat::Flac => "audio/flac", + } + } + /// The container this format writes. pub fn container(self) -> Container { match self { diff --git a/crates/audio/src/read/mod.rs b/crates/audio/src/read/mod.rs index 56aa942b8..1966d70f6 100644 --- a/crates/audio/src/read/mod.rs +++ b/crates/audio/src/read/mod.rs @@ -90,6 +90,25 @@ fn detect_by_magic(b: &[u8], depth: u32) -> Option { None } +/// The file extensions of the formats [`decode`] reads, lower case and without the dot: what a +/// file picker or a browser filters by. Each is a hint [`detect_container`] knows; a file's +/// contents still decide what it is. Without the `symphonia` feature the formats only symphonia +/// decodes (FLAC, MP3, CAF) are left out. +#[cfg(feature = "symphonia")] +pub const DECODE_EXTENSIONS: &[&str] = &[ + "wav", "wave", "bwf", "rf64", "aif", "aiff", "aifc", "flac", "mp3", "mp2", "mp1", "mpa", "ogg", "oga", "opus", "m4a", "m4b", "mp4", "mov", "aac", + "adts", "ac3", "eac3", "ec3", "caf", "mkv", "mka", "webm", +]; +/// The file extensions of the formats [`decode`] reads, lower case and without the dot: what a +/// file picker or a browser filters by. Each is a hint [`detect_container`] knows; a file's +/// contents still decide what it is. Without the `symphonia` feature the formats only symphonia +/// decodes (FLAC, MP3, CAF) are left out. +#[cfg(not(feature = "symphonia"))] +pub const DECODE_EXTENSIONS: &[&str] = &[ + "wav", "wave", "bwf", "rf64", "aif", "aiff", "aifc", "mp2", "mp1", "mpa", "ogg", "oga", "opus", "m4a", "m4b", "mp4", "mov", "aac", "adts", "ac3", + "eac3", "ec3", "mkv", "mka", "webm", +]; + fn detect_by_ext(ext: &str) -> Option { Some(match ext.trim_start_matches('.').to_ascii_lowercase().as_str() { "wav" | "wave" | "bwf" | "rf64" => Container::Wav, diff --git a/crates/audio/src/read/wav.rs b/crates/audio/src/read/wav.rs index 9208aaaa3..bd9c2301a 100644 --- a/crates/audio/src/read/wav.rs +++ b/crates/audio/src/read/wav.rs @@ -271,9 +271,10 @@ pub(crate) fn parse(src: &Src<'_>) -> Result { // A size of 0 in a file whose RIFF size is right and that has only chunks after it is an // empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording // that was never finalised, and what follows is audio. - let len = data_chunk_len(src, DataChunk { body, size32, is_rf64, remaining, declared }); + // Only the first data chunk is used, so its length is worked out once: deciding whether + // an empty one is followed by chunks alone walks the rest of the file. if data.is_none() { - data = Some((body, len)); + data = Some((body, data_chunk_len(src, DataChunk { body, size32, is_rf64, remaining, declared }))); } if declared > remaining { break; diff --git a/crates/audio/tests/audio/decode_extensions.rs b/crates/audio/tests/audio/decode_extensions.rs new file mode 100644 index 000000000..e8fe22d58 --- /dev/null +++ b/crates/audio/tests/audio/decode_extensions.rs @@ -0,0 +1,72 @@ +//! Every extension `DECODE_EXTENSIONS` names is a hint `detect_container` knows, and a file of +//! that kind probes and decodes. + +use craft_audio::{AudioBuffer, BitDepth, DECODE_EXTENSIONS, EncodeFormat, EncodeOptions, decode, detect_container, encode, probe}; + +fn fixture(name: &str) -> Vec { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name); + std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +fn generated(opts: EncodeOptions) -> Vec { + let tone: Vec = (0..800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect(); + encode(&AudioBuffer { sample_rate: 8000, channels: vec![tone] }, &opts.with_bit_depth(BitDepth::Int16)).unwrap() +} + +fn bytes_for(ext: &str) -> Vec { + match ext { + "wav" | "wave" | "bwf" => generated(EncodeOptions::new(EncodeFormat::Wav)), + "rf64" => generated(EncodeOptions::new(EncodeFormat::Wav).with_force_rf64(true)), + "aif" | "aiff" => generated(EncodeOptions::new(EncodeFormat::Aiff)), + "aifc" => fixture("tone.aifc"), + "flac" => generated(EncodeOptions::new(EncodeFormat::Flac)), + "mp3" => fixture("tone.mp3"), + "mp2" | "mpa" => fixture("tone.mp2"), + "mp1" => fixture("silence.mp1"), + "ogg" | "oga" => fixture("tone.ogg"), + "opus" => fixture("tone.opus"), + "m4a" | "m4b" | "mp4" => fixture("tone.m4a"), + "mov" => fixture("tone.mov"), + "aac" | "adts" => fixture("tone.aac"), + "caf" => fixture("tone.caf"), + "mkv" => fixture("tone.mkv"), + "mka" => fixture("tone.mka"), + "webm" => fixture("tone.webm"), + "ac3" => fixture("tone.ac3"), + "eac3" | "ec3" => fixture("tone.eac3"), + other => panic!("no fixture for the extension {other:?}: add one"), + } +} + +#[test] +fn every_decode_extension_probes_and_decodes() { + for ext in DECODE_EXTENSIONS { + // No magic bytes here: the hint alone names the container. + assert!(detect_container(&[0; 16], Some(ext)).is_some(), ".{ext} is not a known hint"); + assert_eq!(ext.to_ascii_lowercase(), *ext, "extensions are listed in lower case"); + let bytes = bytes_for(ext); + let info = probe(&bytes, Some(ext)).unwrap_or_else(|e| panic!("probe .{ext}: {e}")); + let (_, buf) = decode(&bytes, Some(ext)).unwrap_or_else(|e| panic!("decode .{ext}: {e}")); + assert!(info.channels >= 1, ".{ext}"); + assert!(buf.frames() > 0, ".{ext}: no frames"); + } + for known in ["wav", "aif", "flac", "mp3", "ogg", "m4a", "caf", "mkv", "webm", "opus", "ac3"] { + assert!(DECODE_EXTENSIONS.contains(&known), "{known}"); + } + for other in ["mid", "txt", "wma", ""] { + assert!(!DECODE_EXTENSIONS.contains(&other), "{other}"); + } +} + +#[test] +fn every_encode_format_has_an_extension_it_is_written_and_read_under() { + for &format in EncodeFormat::ALL { + let ext = format.extension(); + assert_eq!(craft_audio::encode_format_for(ext), Some(format), "{ext}"); + assert!(craft_audio::ENCODE_EXTENSIONS.contains(&ext), "{ext}"); + assert!(DECODE_EXTENSIONS.contains(&ext), "{ext}: what is written reads back"); + assert!(format.mime_type().starts_with("audio/"), "{format:?}"); + } + assert_eq!(EncodeFormat::ALL.iter().map(|f| f.extension()).collect::>(), ["wav", "aif", "flac"]); + assert_eq!(EncodeFormat::Aiff.mime_type(), "audio/aiff"); +} diff --git a/crates/audio/tests/audio/main.rs b/crates/audio/tests/audio/main.rs index 84b90aa19..d24556b09 100644 --- a/crates/audio/tests/audio/main.rs +++ b/crates/audio/tests/audio/main.rs @@ -1,8 +1,10 @@ //! Integration tests, built as one test binary: each file is a module. +mod decode_extensions; mod encode_format; mod float_headroom; mod malformed; +mod mp4_length; mod roundtrip; mod stream; mod wav_empty_data; diff --git a/crates/audio/tests/audio/malformed.rs b/crates/audio/tests/audio/malformed.rs index 0c48968fe..d69fb6879 100644 --- a/crates/audio/tests/audio/malformed.rs +++ b/crates/audio/tests/audio/malformed.rs @@ -113,6 +113,15 @@ fn zero_channels_is_malformed() { assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_)))); } +#[test] +fn aiff_negative_channel_count_is_malformed() { + let mut aiff = small(EncodeFormat::Aiff, BitDepth::Int16, 1); + let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap(); + aiff[comm + 8] = 0x80; + aiff[comm + 9] = 0; + assert!(probe(&aiff, None).is_err()); +} + #[test] fn zero_sample_rate_is_malformed() { let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]); @@ -211,6 +220,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() { assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); } +/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and +/// `u64::MAX` writes the reserved "unknown size"), then `body`. +fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec { + let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size }; + [id, &size.to_be_bytes(), body].concat() +} + +/// The EBML header of a Matroska file. +fn ebml_header() -> Vec { + let doc_type = ebml(&[0x42, 0x82], 8, b"matroska"); + ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type) +} + +/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size +/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch +/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way +/// for the header, for codec data and for block frames. +#[test] +fn matroska_sizes_beyond_the_file_are_errors_not_aborts() { + let huge = 1u64 << 47; + // The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47. + let reported = [ + 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D, + 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61, + ]; + let segment = |body: Vec| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat(); + let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat(); + let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info); + // A valid 16-bit PCM track entry's children, followed by `extra`. + let pcm = |extra: &[u8]| { + let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat(); + [ + ebml(&[0xD7], 1, &[1]), + ebml(&[0x73, 0xC5], 1, &[1]), + ebml(&[0x83], 1, &[2]), + ebml(&[0x86], 13, b"A_PCM/INT/LIT"), + ebml(&[0xE1], audio.len() as u64, &audio), + extra.to_vec(), + ] + .concat() + }; + // That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the + // segment is valid up to that read, which runs out of file. + let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16]))); + let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat()); + // The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data. + let track = pcm(&[]); + let track = ebml(&[0xAE], track.len() as u64, &track); + let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat()); + let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat()); + let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file")); + for hint in [None, Some("mkv"), Some("wav")] { + assert!(probe(&reported, hint).is_err(), "{hint:?}"); + assert!(decode(&reported, hint).is_err(), "{hint:?}"); + // Refused at the oversized read itself, not by anything before it. + assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err()); + assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}"); + // The file and its track open, so decoding reaches the block; the block reader stops at the + // end of the file, a best-effort read of what is there. + assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err()); + assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}"); + } +} + #[test] fn well_formed_caf_still_reads() { let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); @@ -310,6 +383,7 @@ fn packets_longer_than_any_codec_uses_are_refused() { assert!(matches!(decode(&mp4(480, &alac(1 << 21), |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); } +/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed. #[test] fn flac_metadata_fields_beyond_their_block_are_refused() { let with_block = |kind: u8, body: &[u8]| { diff --git a/crates/audio/tests/audio/mp4_length.rs b/crates/audio/tests/audio/mp4_length.rs new file mode 100644 index 000000000..aa0bc2948 --- /dev/null +++ b/crates/audio/tests/audio/mp4_length.rs @@ -0,0 +1,63 @@ +//! The length `probe` reports for AAC in MP4 is the length `decode` produces. + +use craft_audio::{decode, probe}; + +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s. +fn full(kind: &[u8; 4], fields: &[u32]) -> Vec { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + atom(kind, &body) +} + +/// An MPEG-4 descriptor with a one-byte length. +fn descriptor(tag: u8, body: &[u8]) -> Vec { + [&[tag, body.len() as u8][..], body].concat() +} + +/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END. +const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07]; + +/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts. +fn aac_mp4(packets: u32, last_dur: u32) -> Vec { + // AudioSpecificConfig: AAC-LC, 48 kHz, one channel. + let dsi = descriptor(5, &[0x11, 0x88]); + let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat()); + let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat()); + let esds = atom(b"esds", &[&[0; 4][..], &es].concat()); + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat()); + let total = (packets - 1) * 1024 + last_dur; + let moov = |offset: u32| { + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat()); + let stbl = [ + stsd, + full(b"stts", &[2, packets - 1, 1024, 1, last_dur]), + full(b"stsc", &[1, 1, packets, 1]), + full(b"stsz", &[SILENT_SCE.len() as u32, packets]), + full(b"stco", &[1, offset]), + ]; + let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); + let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)]; + let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat(); + atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat()) + }; + let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); + let offset = (ftyp.len() + moov(0).len() + 8) as u32; + [ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat() +} + +#[test] +fn aac_in_mp4_probe_length_matches_the_decoded_length() { + // A last sample timed longer than the packet it holds, and one timed shorter: the track is + // presented to the end of its sample durations, but never past the audio the packets hold. + for (last_dur, frames) in [(1145, 10 * 1024), (546, 9 * 1024 + 546)] { + let file = aac_mp4(10, last_dur); + let (info, buf) = decode(&file, Some("m4a")).unwrap(); + assert_eq!(buf.frames(), frames, "decoded frames, last stts duration {last_dur}"); + assert_eq!(info.frames, frames as u64, "decoded info, last stts duration {last_dur}"); + assert_eq!(probe(&file, Some("m4a")).unwrap().frames, frames as u64, "probed frames, last stts duration {last_dur}"); + } +} diff --git a/crates/audio/tests/audio/wav_empty_data.rs b/crates/audio/tests/audio/wav_empty_data.rs index 6a8a2c589..2802d109e 100644 --- a/crates/audio/tests/audio/wav_empty_data.rs +++ b/crates/audio/tests/audio/wav_empty_data.rs @@ -85,3 +85,21 @@ fn a_data_chunk_with_audio_is_unchanged() { let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]); assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100); } +/// Whether an empty `data` chunk is followed by chunks alone is decided by walking the rest of the +/// file. Doing that for every empty `data` chunk made parsing quadratic in their number (minutes +/// for this 4 MB file); only the first one is used. +#[test] +fn many_empty_data_chunks_parse_in_linear_time() { + let mut b = b"RIFF\0\0\0\0WAVEfmt \x10\0\0\0".to_vec(); + b.extend_from_slice(&[1, 0, 1, 0, 0x80, 0xBB, 0, 0, 0, 0x77, 1, 0, 2, 0, 16, 0]); + b.extend_from_slice(&b"data\0\0\0\0".repeat(100_000)); + b.extend_from_slice(&b"junk\0\0\0\0".repeat(400_000)); + let n = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&n.to_le_bytes()); + let t = std::time::Instant::now(); + let info = probe(&b, Some("wav")).unwrap(); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "parse took {:?}", t.elapsed()); + // The chunks after the first empty `data` chunk are metadata, not samples. + assert_eq!(info.frames, 0); + assert_eq!(decode(&b, Some("wav")).unwrap().1.frames(), 0); +} diff --git a/crates/audio/tests/fixtures/README.md b/crates/audio/tests/fixtures/README.md new file mode 100644 index 000000000..60abb7ef5 --- /dev/null +++ b/crates/audio/tests/fixtures/README.md @@ -0,0 +1,27 @@ +Tiny fixtures for `tests/audio/decode_extensions.rs`: one per container or codec that +`DECODE_EXTENSIONS` names and the crate cannot write itself. All are a 0.1 s mono 440 Hz sine +from ffmpeg's `sine` generator (`F="-y -f lavfi -i sine=frequency=440:duration=0.1 -ac 1"`). + +From SoundCraft (`crates/audio-io/tests/fixtures`), made with ffmpeg 8.0: + + ffmpeg $F -ar 8000 -c:a libmp3lame -b:a 16k tone.mp3 + ffmpeg $F -ar 24000 -c:a mp2 -b:a 32k tone.mp2 + ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.ogg + ffmpeg $F -ar 8000 -c:a aac -b:a 16k tone.m4a + ffmpeg $F -ar 8000 -c:a aac -b:a 16k -f adts tone.aac + ffmpeg $F -ar 8000 -c:a pcm_s16le tone.caf + ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mkv + ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.webm + ffmpeg $F -ar 8000 -c:a pcm_s16le -f aiff tone.aifc # AIFF-C "sowt" + +Made here with ffmpeg 9.0.2 (`-bitexact -map_metadata -1` added to `$F`): + + ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mov + ffmpeg $F -ar 8000 -c:a pcm_s16le -f matroska tone.mka + ffmpeg $F -ar 48000 -c:a libopus -b:a 16k tone.opus + ffmpeg $F -ar 48000 -c:a ac3 -b:a 64k tone.ac3 + ffmpeg $F -ar 48000 -c:a eac3 -b:a 64k tone.eac3 + +`silence.mp1` has no ffmpeg encoder. It is four hand-built MPEG-1 Layer I frames (mono, 32 kHz, +32 kbit/s, 48 bytes each: header `FF FF 18 C0`, all subband allocations zero), which decode to +silence. diff --git a/crates/audio/tests/fixtures/silence.mp1 b/crates/audio/tests/fixtures/silence.mp1 new file mode 100644 index 0000000000000000000000000000000000000000..b26412296797df79dacb48d7a2a7eba87aa32d81 GIT binary patch literal 192 ScmezWU*Z4*3E=-AQx5<{j|*7< literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.aac b/crates/audio/tests/fixtures/tone.aac new file mode 100644 index 0000000000000000000000000000000000000000..98cb40c069a72024a1cf71db976c5aa62dc0e242 GIT binary patch literal 711 zcmV;&0yzEu@oYdazx>_;08C+aV>U7_F)=PNFfafDBxhRbOtL#{Q@s5@!;4hBlB7(C znF(R&*62UAM1$A9>&#tKva|0qcTXi|o&DRs#l&r`6S1~VI?6ixn|2l}qPr($Xk1Hv z^H&kgTldbqH>|cTg^J^=Cg$8ZdpO}+_wH3FTvN_>4w`(X{txZFmx{3*{oi?Y#?74Q zU6(55JqwoTTz9d0Pa?+fTxXYMwk+pf%W15*t4&#~m1eC9+IE_%szy4i4Ay$%dVP|e zys9UmGDZ7+utToc(DxqbWHLHzS;iH7uXyHZ}>f6(Xp(I+$&U?{G@NB;qEvaRoLZ^sXQEVjrJP!+#h!^#=#?F+oLZ^X9*I%XSQe^r zXPr^fSQe@UhIL1xSQM58hII(8N9`TMy5xS*`yJOsazA6ZcU+Aq{f_1$m5;Lt-W-&#VUo2P8 z#MRQINz6nJLv2PwJ^z7&L9rjkR>CD%M4{5m2&IMhlIyQ$`DnNlew%jA4A$)l6V> zS0yQe&1{sW3yDch6^=RF(BC&cUD+DV4?Nql=W|CtG#PhfYcxFbZht$PIr*T<>y2jS z=bLlwIisJN46eA-eBAkWKGTr%&AThEG~YB6ZphP+^Ub>}t~B2?8C`v+A?KTO`P^x~ tXfnFvPD9T&=h|^i^Ffu@8qE(p+n;I3Tjql+t~HyRo^8*xEh7v>*!HXrcch9Kilw|l9I zWNs~?u<@(U5fpMR*Bz{;P?quR43af)6Fa>u^BlCyD%WJ1P501$98*p-Of1W7B1dvUx0C>j6^>o1RsSpyZ5VJ;rKz1A-ZF70SB=zJs zLTC^NMo)3<($8r!0Vuf$O=1D}Wq(G#N9Ht5{#E{33_v7!PE&D^#th@TqfBPGfB*mh z0G^Tyca*INKre(TLQNCL^5VBtRl7eQTc^*TJ+B6pdO5AWeD^k}Z zL4FV*3vp&5u#R9U!%9%C(;CnVhWI3EYGp`aYLql}(tvhT2zwk>#rm+RmHDdYs}|^n z8$!dg$0%vE*So?IHHaxdH8#N-$JVEMTj?|TIvb#4do{p7;wA|S)S81B8(R+osUQfO z^Yk;Xq!f%GT7`HOO$EE|ekX%mjYY$99WY zI52L;9g}pVLgT$6F(?Nd(T|eKqpyk;R{wHNcusGYzgt{!JcDMdi!F>LUC~0203Zqr zcaJCtKre(TLQNCL@+xG?E;@M;^V)A|y@g%vYSrsLeC9Ma(9pho`SYbuOux9$L*X@{ z5eW$+$afkmJM_<6wzZzzkZWbFMo}dy#c*n)nm{wj8u|JNPPut^()cV#Csysa+ga39 z2?o0*aD*vhi3rBf4?&yXrtJ`?pfcm%jZ*VHzMmb;eVhg)0hXE|V_gL{#>4>nisE_7 zQG$cPufU(%QMqoU&S+Z@y3MpDs#fH9ShteV0K|hQV$c_~U}Wg4+BIw58^Da)41}4x zTINkTvTG!c}qY$al7ha>SP)iwkwTEhdALyAB)o&)+_N1{IaX{iN;D zHT)D4Fr!{RJEvHY<6;25wuSC+wbY9iS|0gtxTYxGL|%0x$`q7qdcwlsLv|njh~@;e zDuHWufQ<+Pg1LTurURNgd~6abJ04ulD>GeHOgpQ6P0Bncokj>eawj5({ObAh=g*%$ ueEIX|&!0Yg`Sa(`pFVu~^XJc>K79G}=g*%221!5w$tVCBB>)2?fGXq2eCp`{ literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.aifc b/crates/audio/tests/fixtures/tone.aifc new file mode 100644 index 0000000000000000000000000000000000000000..628dee163a5a2b325460dd729cc9874943c505f3 GIT binary patch literal 1672 zcmeIxO-K}B7{Kv&zIJzJXXY)u6clLGJ$C~S8>nrGW~S|eLaiqQgVdlRF=#(VNa_-p z9t^vb1wxt~)Jq~kd+^}esf&R^K_}sT=VNDgW@o0i)fSbC&f#-=9(Yba{!Q(%HUNC- z#+IgNQ|E;k0DygwzS0nFZzFeb1W*7l)!ajhM&#DTlLJ#qre!7@+ zEyfLCF#%2ZR-O(<{WLvX8ZZ;u3Sv+-26}5D<#_bTKwOld2PGI6PXz#zb}kiwUV|C5 zf)ZN4Ib5RXbN=bztZc#r#G)IpnUq^woYO~e6;e=Eoyb43De9_E6B^_VI0+Rg$E$4G zJ8tzGB^*XAD5BorEy7jE$z#GdE(4$)&L`FDWe<^fc)L7?Djsyjqf=k{ zgU5QsV}^KuvWFM^F|90%|>Gdufdc4l7TNhr{$tLSdzVFNWgWD1EwLam2o*VSlU!@@9V{|QN5 z0@H(Grz{ZC?4Vu}3EG1P*G^pw3<^34Z~pD<&g{(eoZS|ciq7E=9>3r3@w9OSOK>7Q6pG`%Uo42&fA(@K9v!g<0BUh32IAq7 z$iOJ*3XMfC_!|5TO^uBp61o>EA2~1Y_cKHbeT7-&KERKV<)1P?=p;C)H|3LJ7}cW< zfm2=;O>@jK=m~HMxCbBb-QG{m4B;$MDwO7t2lWVj^812mJfx=F^K3IT;8z~7E3Pz2 z7+=&yNkRmY&>Lw-C5#lg;sWe0Z@^|a&pvY}C}6G^`s5zLgI*#ikt$tE6Wh*f-f?~p zCSiaZ^xSt?=IbJ-YzXxzj1r zT7o0z$#8RU7q;;-bJIx@PxRS*R?MIp^hj8i8;c23qfEEJ2B8Yy@(bQ}7fDW(;%ZRZ zL=>tMfZSO~8Wwrh-OF;&f*kB+&$$7WGP(*tt`{h@iGtFwI#D9XORfd)60gD_6j;-( zQ%SR_xGYZ!H7J9!;%xr0P7pVo64T1>!a11Xavsr zS3LR;c)YVc(uX|Su*Zu79x+81&f6Ya%8Uj;*<~Wlr<9uv8|NnfXxor<} UWUN|kRFQ-h6hPhLqVie$331#Y+W-In literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.eac3 b/crates/audio/tests/fixtures/tone.eac3 new file mode 100644 index 0000000000000000000000000000000000000000..6fef82fcdcf5fedcaeaea23c50c4cbe3125a9b76 GIT binary patch literal 1024 zcmd-sXQ(%7Kfs{K`at3F-thIjpTD1<&o=ev{d)&ii$9YuGn4;tzpcaKa@qS*iDk}( zIo}Q+TW{l{@ku=Oc3B_B6n-xIP&JpLs{Un_bu-_t%9?Mt=2dTlO>%P8x*4)xm6$>&vYlMu(eTE?Zrjd1 z4<=Q8l0A`fy}`<;y6@-Gd6!a{0&{sbNe9`zS^r||Cws$1`JYz*xM{jl$VOdrosq=B zIprl8hjlhxX<&%j%?!Ogi23Q| zb~P)5Upcb=aA?pyI-~2rGp0EvlUm!I56et{r9ENmw1u0tw`iz5NwC&zvF+1N`Wq3f z{Wt=6pn%Agj`{PyrneRJ_SA;pc-C!1CI5A5h>V@Vu zGv-&?ozFGhxcH_1aK5|k_VkkG1*HkwLg%L464MHMeCX(&Y0W}BbGselE;df<+B<*M z`-+tO+40h%JU(~V7W-Omj#TZ`c)%LDj_K}&iOnjzPVj3jD84r#>bb93$kVK(JGTp6 zvg0L=Co$aJ3G=^U>jH%%|6MU?G` z{hsO{JS&TSALg0bt)81VQDZV=@>-X>8y_t==V1TlNw#lJd+MSjZ)8CH3aVcfU;&A15*Fjdt4ICmrjtF?HelX}`OY+O~4+ zt6Fi>QDx)RUxyAoa4tPxd!gS#=)x-2wQC#PT^V$^Hq}{YHazRQFU```cYL~W*O}`f oq3m7PirMCsN+^6h8qxme)z8n*&(8maZfn=$)oRb~ zSXNs+jQ;*ER3j$%TshyYnq~j^-|n5>wNd8p{mRl~XXm_#ov!uNmV4TZnDUKP{z}I_ z#H3G8-KO(A?sVpw9jkpK*Gx@Y%XO=4)~Z!*R;zigRXwh%oiulEtW?t9k{zd3L~n}J z+&v|8jp?PwS_@NWny&7Rc(}ObM#e)oiC2%+taf~u%`260SWa5yc-W~~epVTia*a>+ z^G-FHlbXILq;l1qsFklkjHN)v(RrPLFQ&?Z#IDM9240E~TUD4o7fiFZmAWsA5Sa#~ zIo7$ql|8)cN!`WYve#XU?2~tt$MkCa?R%#5BvbZ~o3aOp`EW_O5rpZvBvbax#AeSe zo%-I#Do*Y-pZSha$arl?nxX6Tc%AU`N3LF4;(5l!#l^$!^~wGBZvOxAMSWwwoaUD| z&%et4OlMx2F(LZEzxYcp&Oa`*e^*zzRbq|5PhmvxFV~vjvxSjIteAezo>O*)nQL*r z%u?ykyeEI^uQ_9V+M4;JpM_rH{ms&TUh+bRjxzoFx_HxyjgHqcR<7AFV_E48_2)Bv zd$*RX)Vk)nRWM*vup4(Ti0JY3xq7uHvF7SkpM>IYRn4ZeIa8K#cY6AY_F3M!aNpc6 z_NLTXew*8yo)^2@n@E^Sv0*oOI;C!(J< zYp*k%J$qj6`7KX$fim|P7?_!I^YhDq2|6dYEF%R-BLU_YKu!t+BLfH_015{vjK+6B zsKmx%0%jWBlA^?Hpkz!*HrP&PB!w{3kln%v7KF*b6r=GSVB!#3B{ij_7|4`K%_%N{ zN`Z6$&1HeQnQ>`uN@gNZOd&S~-S6@a3=HfFp+~?vbu&_Oioh%&QJi0z2V;ZiU?4xm zBQY-}Cl#cIF(5ZHFAc~LD9%MOR3!x>E|XG}3X%r_kb`(ii*ghoY(|mdlB66UJFB>) z7#08^HBq?*CLo7`0EiC(j0^%G2FR&EV3t~(0`f2eb3;ReDo_~wwrA$Orl)IUgzx(xU^!QpM>7DIiW!eh$c&U~~Te2eM>}lhO--G)xBBmc-JO zl0=|XWNs>$1}RfSju2oZ79|!GfJ}q~olHoqtE?=^2RTCqSW1Ho0T~P`tJ8pGwK1ru F1^{kZ%L)Jh literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.mka b/crates/audio/tests/fixtures/tone.mka new file mode 100644 index 0000000000000000000000000000000000000000..5eb9ac934b030f785053385c6bdd159ef7f54a52 GIT binary patch literal 2121 zcmeI!eP|nH90%~{E=lv!yL+6YqOxII>OVu*H*so(<*l-~rY%io)pm?7-6HfwUApQ7 zn|m&?$u@Pkb;w*rM(UuftsBzzk2+G(!Unb*hz^-h#limBf*SwyWOKi8HqQ(F8 z;a+&|;hy^)pZnh3!;Q<4h;}SR_4RaFL&~9d5c!~s^%5Z8 z3}qXfrw8Vv!9d5rW6rswu5-aM$M}BN1UggbJ>Z_|QF`Av=iXJu?stFXbxm+YRiV4d zHKz=6`sA&`Y`~>X`tNS?^_-0c9W&L!mTMOR&}w$F!KSXk{p}WYXFnV^zf`znzSwW0 z!L8FzRZSHuvR^}lZy{VPi5Nwoa(Xmyu)jNu!0>yPJ*_rpwWHZx-Q;iw5c%^5pCDp7 zY>t}M6ZUqa`*!_`K>oIWhz56f_dV_NUG>Z#^{mb4y>z-U5^8L=wb&cm?4kFS$~@(- zdHVMFYM<=g=e=~MF%q`#YO}Zc)MaaJO|gMTY%>R+dp#Pwb)$0gO#~vA z7ZCt#C9z$%!!$#G3BQIEbIf#8=LZ|owF$rA!4){l)A3J}x!j=EYiyv0;BEL8(^`79 zgd<8a8VQPH*ofQs_ShFIx$N7TGNhr4dei_kY_@3y#430VzKlD?J}HnPNUMIlbc&H+CyZF$M4*i2shlCL@#kuj z=VKv$Bj)gea4K;)O%N}ah$b_$3`ZeHFBt_bD{oHq#WQ?8cHjnKDL#^t;Vi`o>adxMo zQ(n~RmpDboR@B)I*H|v7Ea)y4i^B5p9W1@^H2;LkvKT*^0-(S$a~DgUl9mF?e>W}a zGjC_tADT6m|I_l%(h^A^(81@?}nLKIz*uB{@bl5tKE@h+pZKJJH^lQ!JDXH&1+ZPV&FiSk0 z4Yyb)rA}70`%ZQ?V9iYW?_SdR%m&=$R%P+_8&`Xw$xy?bs&4K+TwYeJFV>ZoHX7H3 zPXD`;d-6(RFHDz29_{a}y&inReB5hzE}K<)J=X*74*j;TMwgQ|-#~;j=zRrrCN9`w z^MeQ!jScp8b+mX9=zGU_u)eaU#8PW3skYd95&6@57ZEY$H3STq2@kcPP5XX9ApK?x z?tpu$aOcZT=SBN85HNuSJS^Od5=n*} z%K_R2Ith0`zxJ%EM+Q<;i;bZMPK|vS3S?BVgota(y4+**UFd=pltcZ7Tufb#QQ-hr zi0!zCFN@F#2K=n(*U0HPI01u{Uel%k;9Pt@T*2?bdYs_e!dv1lk(1A>S!xh2KqDQ} z>{pV+iNru8$g?;f_i>O~MLd%1cwQ-J4DpEBN_ve_Rw>6=j+r zH38?Kg6`McQXl{m`bZDol3~$GEaDoLV=FA=3d=L0sXt-4xxz9cv49MVnEN}+8Hq(q z{t-+43d>7>!J?2@#wC_AiG|3p?Bp%?u%LTb-d|?%tgs{{mID&YXoiLOn=C$KHv*3_ zdnKKMWu1;FE9q1&>l}cqELSruXdR1DV!5z}r7fJ`9?!7Mh0nzS$g+&DV=0rWCCl>P zt(MF;Z)P@LG^;HCSIa-E7GDH`{Y?5-wy~PoXtDVG2pnPrO}1nZbxn=stoUI3NLb)@ l;R;;OkA%OCr}qNq18&%L^z!8CPZ5|nA?-mpmiPuE^>1?m)z1I` literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.mov b/crates/audio/tests/fixtures/tone.mov new file mode 100644 index 0000000000000000000000000000000000000000..e2ed69f4411c249201554cd66d94a7e03a0ba489 GIT binary patch literal 2253 zcmeH}PiWg#9LJv|%kdvcucbX?kWpu3w++^$(1Qz2*B#Qe(9l2&n|tsqw+@l)&QcRn zm>s*YvRwvUdT41+%L;|2%??d3D`l`8dPuU-ErbCybNxp<%<7>ir{FiW*3bz;w8w(^#&S@8FKW;kq z7tGDb0(TVK_;&$uPx>k}W&dU^8U~?=K^__}Ey|vwpY$PaL$L8tyuf`MDKW@-wt1m; zPK%Q72sUVoZB?lC@Y(pR@EVtKlD{0C3AxVwE#7>t4U!Bg>BDuQIfwpW=VKvZ6+gu* z{76h<5xUh_Gn4wqWP~&|$sA~uT`ja0t@3yA20kIwVxNX->g(q1`ifp92gsM&QtgAS zlG9+Eh{mO`g&zwK;wSrP`g$vGrHmJZAqfrDPH&cNmmZ2ta6EP~k0-cyBZ!%^&upUF zpvI6FBxT6f^%g~c*!Lj*NU(4UYn&5lGG*uR)>7?;c7RmLihjHPRgu^alZEKZ+N`y@K^*M$mik4w|U$I9QfIVTmB!qvR* z4PM*?IK;7ZUX-$sMD@#)`AZPnr^r2`?`?~SAab=RCAvl8AEniFM*|IsZH)o3nDw+g z^FEmi_IdLqMg2^I@r+u%I^F{Lombzty#MxlM);2{O;&*akWLx?@s*srMmrFl7^ ko)gSlcgGGw?3y3({!-d|z?UfmJ}-ahFEqqgOaK4? literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.mp2 b/crates/audio/tests/fixtures/tone.mp2 new file mode 100644 index 0000000000000000000000000000000000000000..a324d26376d6335b8772f49204aad59dd53601e2 GIT binary patch literal 576 zcmezW)#XTvlSY%*CI$uu5%&N87bS}oT#L4!c0N#_I(iGKESt1v+qE^j&^RsywO#tM&9b#|YTyQwUe(1%mQ2if z#Cy59;qgg7kM#^m>k|voa-^5CiQTddW}eN}D4k$=LMPTD_p+NyBFDAK>J9%P?w6$pr_=qJ-O>5pRb$se7RJ5W4fV=jHmSynX3{^ryAloMI(JyOwRKJfYK4EBo?_;=%W(DS3;`mPjyw-EYJJ34dS|FjW1QIDW?VbI_l! z_O9WVqQ8XwHT~~de);*A?SI|k{maj%`YpTt{46MhKL>`f`))r!>)O_}+0S-mRL{GqaYe2E)w$JMdw&wcY r*U7n`p8-vY`nkUP+A)~%ao^YW!i?YhK3W=PeD%F;X7}wNam)Y!bj=%| literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.mp3 b/crates/audio/tests/fixtures/tone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c6429ac0a6b92b2ba1c608ecd6d2ba068e46f500 GIT binary patch literal 836 zcmeZtF=k-^0i}@OU{@f`$H2hslUSB!W~67VXJ}vmmV^-hA6p!Nb3OCY@_`(FAO;%1 zEWuEM11!P?F5&|JaRHF!K({3W-DYSA;=q6k^8yHySwic&Pa#a0Nc#U{jUx4Q=_xJbr_xJBUKHe{9Q}L<){{HaQ_un55b5u>Z&2Wcd1K+`YsYm-6A0MB{ zEKnm~$6@s@{#?mN3x!vW#=ji-;_r4aB>bD;yj)M7g;}cp>A?>*b{tHMtqhC~3>Op* zY|uDpP$9tferERnKL;5?4n8;M0#QJ$iq=qPZpsC&vRnTO~8|Nqn}s7d3WleTEoBc2FmQEB zH2z$nHgh5GncZD0nq+u8k`?$DX=r%{-LzeQ{_mqr%Eu=v*!a8KN35IMTL1t5-=JAb zO}#SP#3FL1Z##V-7{f39y{dvQ$4fRJS#72I#~`R+$;03UD}xs<+JCV~?;Wq`Ty3jR mA4gwTP%1Q3&`3%xNmMY_3X63s571EzkQS3 zch33UbMCqKe7w7P=T0ez0dF5-IAaw_9+wmY$|A~v-TM?ZT2eqM_<(!?&{#41?xJia zHQzs^CIxaizkT^8Y2BN5k2`kO1`R?bI}UudDreN`t`fdnDx^9yOMeeu9EHO4R2>0IrqMincV8K*3zl2pC0s88`N3e%!HiI3_x! z0FVLTwm;-{ED?89SD95+X6r%oxy$wsUprSuoX=;x$@-SQI4}eNvxR-+d+~@-Y7{R+ z%14CMo|nnO$BgO_=I>M(wgin*nqZEgWfW)l=uJkf7`cusHVKa4SUYmVh>Zw+o%;-eY1Y;e!BD2Y81XT+n}knT z)imCdOg$}!jA1hTr;2*mSuu@kD4=O+%}JuBcUR3x?jh?rdoR~+e|*sTw99Hg)N8X_ z?OvzN?$jJST|=U$5xX6-YP&tsX0IK#p1=*YzB-ZT755Yb4-`1U!y_%c+KF@A*4UsQWxX>h`_1 zMrJ%S0k1RQ`eD-JeC={1U77ZJoB^lvysIzb;zvB1Nzu)pwvK;PI`OjPmacrlT{o_) zlS4Lddxg1ZT&Eg0>9*@s@>dWotuU!>m8+^us(S}i6<-#?$>rt-h<@^A-Mv?;s)>eM zI-T64nygS&y;5y|)m-&;!#!!aeD(H!|<)-f5a z=Mn?0ao{0YE#eu&G|*Jelm%*ViA&qi8bn)y`p;OBMLfb1Ld6bMP<->u*^scwifwR>C*62*KCi2(14V46 z4y>>9M5vITNNmibpsjkm^i~(@9F%}dwCaBJg8cm>p0l=p;rJ-;aW8a7nIfey=%Q@O)5fxDd z3#n`(D5MgX#)R=bI4(jEf+6Q}h_Gx00B5dJ0PE`9(xW~~;=HN^umQ4uu8*nrvj}F> zDDx}>2Ag0)Qv^d9EF5BLgM~;Mq?U8^bP+I=(3n;S=DQJQg z)_yD>nXfNFmlMZRWJ!q1jJ9$7BeJ88>$i&&9L?bzHOC)PY;;_&RPX?J1|52KRm9D$ z<^W?nC?Guu3U|^KusyPKsRGz}VD@#Bds_g3c(y($oPxP%e7|fx38;f4(3dws0#ELKP6FECWdQEZi3b;BGm2I%XD0C8Z2}rr z8iYr1g+VaYy&e_#JGDW?56@CKMFP-u5`ZiTkSrX|G#N>1v~ZeasqFnw2KWBIHt~?$ z1dgEy0f%#E-%>Gbl8ws#--GGD*8!~iiSgh~e8$d7aeh|jyp`lR(BY?oImpx-$ir?C zP>KDUP%$UZP{<{;DNw&@R8|YC9z)agxFU&d!exoDC#Vsd8^^eaFI|G#h;Yu-QcH(S z;_!3|;|WVxR_pN5MQFb}jH&x|@N4gIhh=qq3ocXh{b7ZMLzrc18!WdHn>$r5A!QPW z@3sO~ReBP5P`?K>7GK=kVzT`8+CLsd^MOqPaXBbFcruUJSepRGfuuH|f+w>wJq#6@ zRxqQC(QJgM9?OTaGBklaaWYC|4q|8m8&a@5;O-#xHWXyt%*x&tPxS2G%kY$Fu0|)% zT8RdaOQv&_O5nVYD*HV}zzm2bJN1(3rDp?+HMtM3#nR!8EC$4sfIm?VAKpTZ(a)zs z3Cw@+GXTYjjjgS_p3su%DU4KRdgdb5;-yja?GP8e5XP}$XL<5M2#XwCRfKZqtYM_^ za@$O4KS{rhY5b(5r9f4Xm}YC+_SNsd%DDZjb-AUtWa@c$i&uwq+b5zwQ(ooZj`r!+ z_URwvI?P@7j#YpcTjA{i=o$NJen$YaxvL@L0w{o&EzoEb@nUn^JGx#4vH){gOq>!} zz8m@X(MPW(>DjtJPUNj~*ZMr_qJPfl2UREhvGqeIKJ9%u*DNYa17L&imq-1}6N*o7 jn`$UK^{P;{=&tMFo?*|CR@d|Rlar-CuMP9RNs9Xq6G@dV literal 0 HcmV?d00001 diff --git a/crates/audio/tests/fixtures/tone.opus b/crates/audio/tests/fixtures/tone.opus new file mode 100644 index 0000000000000000000000000000000000000000..34a3844d60bb58a4f1125431c5ce2a363100fc49 GIT binary patch literal 469 zcmeZIPY-5bVn72-8e5APh5ZXki#<{kQy3X77#ntjmH2~{qX0%A&vWX&5Jo+a@{q*z zVm6>iT3T*FYC1?x1c+1flJiqii)?)o%aRpxGL!OwD#0eP{1668LjlMYOFxGOu(@g( z+gPepG%;?^sdIS7z`(U{*7AM-x3ym9DtvG^d%@#lM-LkvOOeY<4zhCP=?d<5k=W3a zvii-8X_Yx4!3LpzO;5y5MS9*i&KVn7vDp6mni;Lk;XX{8iW!rO-o5zMCOqqKjxJy4 zec$UIrK^{?DSmMopy|UZzc=4~$<|jik2JB)t9(%eYnr>vS-42mqvlp>5n(DJ^EF_dfD`Y;PVctU8lEA3@&Ui ZvIOI`r+h0icfH6g_iKqbVE?Xr_V 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 literal 0 HcmV?d00001