mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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).
This commit is contained in:
1 parent
1175abec2e
commit
0e60ef3b0a
26 files changed
+314
-5
No files matched your search
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
+27
-1
@@ -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 {
|
||||
|
||||
@@ -90,6 +90,25 @@ fn detect_by_magic(b: &[u8], depth: u32) -> Option<Container> {
|
||||
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<Container> {
|
||||
Some(match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
||||
"wav" | "wave" | "bwf" | "rf64" => Container::Wav,
|
||||
|
||||
@@ -271,9 +271,10 @@ pub(crate) fn parse(src: &Src<'_>) -> Result<WavParse> {
|
||||
// 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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
let tone: Vec<f32> = (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<u8> {
|
||||
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::<Vec<_>>(), ["wav", "aif", "flac"]);
|
||||
assert_eq!(EncodeFormat::Aiff.mime_type(), "audio/aiff");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8>| [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<u8> = (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]| {
|
||||
|
||||
@@ -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<u8> {
|
||||
[&(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<u8> {
|
||||
let body: Vec<u8> = [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<u8> {
|
||||
[&[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<u8> {
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+27
@@ -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.
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in new issue
Block a user