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 000000000..b26412296 Binary files /dev/null and b/crates/audio/tests/fixtures/silence.mp1 differ diff --git a/crates/audio/tests/fixtures/tone.aac b/crates/audio/tests/fixtures/tone.aac new file mode 100644 index 000000000..98cb40c06 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.aac differ diff --git a/crates/audio/tests/fixtures/tone.ac3 b/crates/audio/tests/fixtures/tone.ac3 new file mode 100644 index 000000000..44020e8f8 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.ac3 differ diff --git a/crates/audio/tests/fixtures/tone.aifc b/crates/audio/tests/fixtures/tone.aifc new file mode 100644 index 000000000..628dee163 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.aifc differ diff --git a/crates/audio/tests/fixtures/tone.caf b/crates/audio/tests/fixtures/tone.caf new file mode 100644 index 000000000..2d3707ee6 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.caf differ diff --git a/crates/audio/tests/fixtures/tone.eac3 b/crates/audio/tests/fixtures/tone.eac3 new file mode 100644 index 000000000..6fef82fcd Binary files /dev/null and b/crates/audio/tests/fixtures/tone.eac3 differ diff --git a/crates/audio/tests/fixtures/tone.m4a b/crates/audio/tests/fixtures/tone.m4a new file mode 100644 index 000000000..8966d86fc Binary files /dev/null and b/crates/audio/tests/fixtures/tone.m4a differ diff --git a/crates/audio/tests/fixtures/tone.mka b/crates/audio/tests/fixtures/tone.mka new file mode 100644 index 000000000..5eb9ac934 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.mka differ diff --git a/crates/audio/tests/fixtures/tone.mkv b/crates/audio/tests/fixtures/tone.mkv new file mode 100644 index 000000000..eaeab78e4 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.mkv differ diff --git a/crates/audio/tests/fixtures/tone.mov b/crates/audio/tests/fixtures/tone.mov new file mode 100644 index 000000000..e2ed69f44 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.mov differ diff --git a/crates/audio/tests/fixtures/tone.mp2 b/crates/audio/tests/fixtures/tone.mp2 new file mode 100644 index 000000000..a324d2637 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.mp2 differ diff --git a/crates/audio/tests/fixtures/tone.mp3 b/crates/audio/tests/fixtures/tone.mp3 new file mode 100644 index 000000000..c6429ac0a Binary files /dev/null and b/crates/audio/tests/fixtures/tone.mp3 differ diff --git a/crates/audio/tests/fixtures/tone.ogg b/crates/audio/tests/fixtures/tone.ogg new file mode 100644 index 000000000..5ebd4a7d3 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.ogg differ diff --git a/crates/audio/tests/fixtures/tone.opus b/crates/audio/tests/fixtures/tone.opus new file mode 100644 index 000000000..34a3844d6 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.opus differ diff --git a/crates/audio/tests/fixtures/tone.webm b/crates/audio/tests/fixtures/tone.webm new file mode 100644 index 000000000..5dbbcfee1 Binary files /dev/null and b/crates/audio/tests/fixtures/tone.webm differ