mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* craft-audio: a program config element in the shape of a channel configuration gets its speakers An AAC stream with channel configuration 0 whose program config element lists a centre, a front pair and then exactly the elements of configuration 3 to 6 decoded as C L R Ls Rs LFE and was reported as an unordered layout, so a caller reading it in WAV order sent the centre to the left speaker. Such a stream now has the configuration's layout and WAV order. Any other element list keeps the decoded order, as before. Ported from storytold/filmcraft#4280 (aac_stream_order in filmcraft-codecs). * craft-audio: the ADTS program config oracle expects 5.1 in WAV order ffmpeg 9 writes its 5.1 program config element centre first, so the stream now has 5.1's speakers: each decoded channel must match ffmpeg's channel of the same index. An element list in another order keeps the old check. --------- Co-authored-by: mpratsaki <110930793+mpratsaki@users.noreply.github.com>
810 lines
32 KiB
Rust
810 lines
32 KiB
Rust
//! `craft_audio::decode` routes each container and codec below to the home-grown demuxers and
|
|
//! decoders, or to symphonia (FLAC): each pair decodes, sample-aligned, to its presentation length
|
|
//! (edit lists, Matroska `CodecDelay` and `DiscardPadding`, Ogg Opus pre-skip and end trimming
|
|
//! applied), within an SNR of ffmpeg's decode; truncated and corrupted copies never panic. Fixtures
|
|
//! are written by ffmpeg (skipped without it).
|
|
|
|
use craft_audio::{ChannelLayout, CodecId, Container, decode, probe};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// Five seconds of two tones and a little noise per channel.
|
|
const STEREO: &str =
|
|
"aevalsrc=0.4*sin(2*PI*220*t)+0.15*sin(2*PI*1375*t)+0.05*(random(0)-0.5)|0.35*sin(2*PI*330*t)+0.2*sin(2*PI*2750*t*(1+t/20))+0.05*(random(1)-0.5)";
|
|
const SURROUND_EXTRA: &str = "|0.3*sin(2*PI*440*t)|0.1*sin(2*PI*60*t)|0.2*sin(2*PI*880*t)|0.2*sin(2*PI*990*t)";
|
|
const MONO: &str = "aevalsrc=0.4*sin(2*PI*220*t)";
|
|
|
|
struct Case {
|
|
name: &'static str,
|
|
surround: bool,
|
|
mono: bool,
|
|
rate: u32,
|
|
args: &'static [&'static str],
|
|
container: Container,
|
|
codec: CodecId,
|
|
/// The speakers of the decoded channels (WAV order).
|
|
layout: ChannelLayout,
|
|
channels: usize,
|
|
/// Presented frames, or `None` for a raw stream (as many as ffmpeg decodes).
|
|
frames: Option<usize>,
|
|
/// Minimum SNR against ffmpeg (AC-3 is limited by decoder-specific dither, §7.3.4).
|
|
min_snr: f64,
|
|
}
|
|
|
|
const CASES: &[Case] = &[
|
|
Case {
|
|
name: "flac.flac",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "flac", "-sample_fmt", "s16"],
|
|
container: Container::Flac,
|
|
codec: CodecId::Flac,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 140.0,
|
|
},
|
|
Case {
|
|
name: "aac.aac",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "aac", "-b:a", "128k", "-aac_pns", "0"],
|
|
container: Container::Adts,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: None,
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "aac.m4a",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 44_100,
|
|
args: &["-c:a", "aac", "-b:a", "128k", "-aac_pns", "0"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(220_500),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "aac-5.1.m4a",
|
|
surround: true,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "aac", "-b:a", "384k", "-aac_pns", "0"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::SURROUND_5_1_SIDE,
|
|
channels: 6,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "aac.mkv",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "aac", "-b:a", "128k", "-aac_pns", "0"],
|
|
container: Container::Matroska,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "opus.opus",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "libopus", "-b:a", "128k"],
|
|
container: Container::Ogg,
|
|
codec: CodecId::Opus,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "opus-5.1.opus",
|
|
surround: true,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "libopus", "-b:a", "256k", "-mapping_family", "1"],
|
|
container: Container::Ogg,
|
|
codec: CodecId::Opus,
|
|
layout: ChannelLayout::SURROUND_5_1,
|
|
channels: 6,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "opus.webm",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "libopus", "-b:a", "96k"],
|
|
container: Container::WebM,
|
|
codec: CodecId::Opus,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "opus.mp4",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "libopus", "-b:a", "96k"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Opus,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 120.0,
|
|
},
|
|
Case {
|
|
name: "ac3.ac3",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "ac3", "-b:a", "192k"],
|
|
container: Container::Ac3,
|
|
codec: CodecId::Ac3,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: None,
|
|
min_snr: 25.0,
|
|
},
|
|
Case {
|
|
name: "ac3-5.1.ac3",
|
|
surround: true,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "ac3", "-b:a", "448k"],
|
|
container: Container::Ac3,
|
|
codec: CodecId::Ac3,
|
|
layout: ChannelLayout::SURROUND_5_1_SIDE,
|
|
channels: 6,
|
|
frames: None,
|
|
min_snr: 25.0,
|
|
},
|
|
Case {
|
|
name: "eac3.eac3",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "eac3", "-b:a", "192k"],
|
|
container: Container::Ac3,
|
|
codec: CodecId::Eac3,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: None,
|
|
min_snr: 25.0,
|
|
},
|
|
Case {
|
|
name: "ac3.mp4",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "ac3"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Ac3,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 25.0,
|
|
},
|
|
Case {
|
|
name: "eac3-5.1.mkv",
|
|
surround: true,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "eac3"],
|
|
container: Container::Matroska,
|
|
codec: CodecId::Eac3,
|
|
layout: ChannelLayout::SURROUND_5_1_SIDE,
|
|
channels: 6,
|
|
frames: Some(240_000),
|
|
min_snr: 25.0,
|
|
},
|
|
Case {
|
|
name: "mp2.mp2",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "mp2"],
|
|
container: Container::MpegAudio,
|
|
codec: CodecId::Mp2,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: None,
|
|
min_snr: 80.0,
|
|
},
|
|
// ffmpeg's default for mono MP2 is 384 kbit/s, which ISO/IEC 11172-3 does not allow.
|
|
Case {
|
|
name: "mp2-mono.mp2",
|
|
surround: false,
|
|
mono: true,
|
|
rate: 48_000,
|
|
args: &["-c:a", "mp2"],
|
|
container: Container::MpegAudio,
|
|
codec: CodecId::Mp2,
|
|
layout: ChannelLayout::MONO,
|
|
channels: 1,
|
|
frames: None,
|
|
min_snr: 80.0,
|
|
},
|
|
Case {
|
|
name: "mp2.mkv",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "mp2"],
|
|
container: Container::Matroska,
|
|
codec: CodecId::Mp2,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 80.0,
|
|
},
|
|
Case {
|
|
name: "mp2.mp4",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "mp2"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Mp2,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 80.0,
|
|
},
|
|
Case {
|
|
name: "pcm-s24be.mov",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "pcm_s24be"],
|
|
container: Container::QuickTime,
|
|
codec: CodecId::Pcm,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 140.0,
|
|
},
|
|
Case {
|
|
name: "pcm-f32.mkv",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "pcm_f32le"],
|
|
container: Container::Matroska,
|
|
codec: CodecId::Pcm,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: Some(240_000),
|
|
min_snr: 140.0,
|
|
},
|
|
];
|
|
|
|
fn run(ff: &Path, args: &[&str]) -> bool {
|
|
Command::new(ff).args(["-v", "error", "-y"]).args(args).output().is_ok_and(|o| o.status.success())
|
|
}
|
|
|
|
/// The fixture for `c`, written by ffmpeg (cached), or `None` when this ffmpeg lacks an encoder
|
|
/// (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`).
|
|
fn fixture(ff: &Path, c: &Case) -> Option<PathBuf> {
|
|
let out = crate::testkit::fixtures_dir("routing").join(c.name);
|
|
let source = match (c.mono, c.surround) {
|
|
(true, _) => MONO.to_string(),
|
|
(_, true) => format!("{STEREO}{SURROUND_EXTRA}"),
|
|
_ => STEREO.to_string(),
|
|
};
|
|
let input = format!("{source}:s={}", c.rate);
|
|
let made = crate::testkit::fixtures::generate(&out, |tmp| {
|
|
let tmp = tmp.to_string_lossy();
|
|
let mut args = vec!["-f", "lavfi", "-i", &input, "-t", "5"];
|
|
args.extend_from_slice(c.args);
|
|
args.push(&tmp);
|
|
run(ff, &args)
|
|
});
|
|
assert!(made.is_some() || !craft_media_testkit::oracles_required(), "{}: ffmpeg could not write this fixture", c.name);
|
|
made
|
|
}
|
|
|
|
/// ffmpeg's decode of `file` as interleaved f32.
|
|
fn reference(ff: &Path, file: &Path) -> Vec<f32> {
|
|
let out = Command::new(ff).args(["-v", "error", "-i"]).arg(file).args(["-f", "f32le", "-c:a", "pcm_f32le", "-"]).output().unwrap();
|
|
assert!(out.status.success(), "ffmpeg decode of {}", file.display());
|
|
out.stdout.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect()
|
|
}
|
|
|
|
/// The major version of this ffmpeg, or `None` for one that does not say (a git build).
|
|
fn ffmpeg_major(ff: &Path) -> Option<u32> {
|
|
let out = Command::new(ff).arg("-version").output().ok()?;
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
let version = text.strip_prefix("ffmpeg version ")?.trim_start_matches('n');
|
|
version.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()
|
|
}
|
|
|
|
/// How many frames of priming ffmpeg before 9 decodes as audio at the start of `name`, which
|
|
/// `decode` drops; ffmpeg 9 drops them too.
|
|
fn reference_lead(name: &str) -> usize {
|
|
match name {
|
|
"aac.mkv" => 1024,
|
|
"eac3-5.1.mkv" => 256,
|
|
"mp2.mkv" => 481,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// SNR (dB) of `ours` (planar) against `reference` (interleaved), sample-aligned, over the
|
|
/// frames both hold.
|
|
fn snr(ours: &[Vec<f32>], reference: &[f32]) -> f64 {
|
|
let ch = ours.len();
|
|
let n = ours.iter().map(Vec::len).min().unwrap_or(0).min(reference.len() / ch);
|
|
let (mut s, mut e) = (0f64, 0f64);
|
|
for i in 0..n {
|
|
for (c, plane) in ours.iter().enumerate() {
|
|
let r = f64::from(reference[i * ch + c]);
|
|
let d = f64::from(plane[i]) - r;
|
|
s += r * r;
|
|
e += d * d;
|
|
}
|
|
}
|
|
if e == 0.0 { f64::INFINITY } else { 10.0 * (s / e).log10() }
|
|
}
|
|
|
|
/// How many frames short of the input ffmpeg before 9 ends `name`: its lengths for these tracks
|
|
/// leave out about the encoder's delay. The decoder presents what the file declares.
|
|
fn early_end(name: &str) -> usize {
|
|
match name {
|
|
"ac3.mp4" => 288,
|
|
"eac3-5.1.mkv" => 240,
|
|
"mp2.mkv" => 480,
|
|
"mp2.mp4" => 528,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_container_and_codec_decodes_home_grown() {
|
|
let ff = require_ffmpeg!();
|
|
let major = ffmpeg_major(&ff);
|
|
for c in CASES {
|
|
let Some(path) = fixture(&ff, c) else {
|
|
eprintln!("SKIPPED {}: this ffmpeg cannot write it", c.name);
|
|
continue;
|
|
};
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (info, buf) = decode(&bytes, None).unwrap_or_else(|e| panic!("{}: {e}", c.name));
|
|
let reference = reference(&ff, &path);
|
|
assert_eq!((info.container, info.codec, info.sample_rate, buf.channels.len()), (c.container, c.codec, c.rate, c.channels), "{}", c.name);
|
|
assert_eq!(info.layout, c.layout, "{}: layout", c.name);
|
|
let expected = c.frames.unwrap_or(reference.len() / c.channels);
|
|
let frames = buf.frames();
|
|
assert!(frames == expected || frames == expected - early_end(c.name), "{}: presented frames: {frames} of {expected}", c.name);
|
|
// One alignment, so audio that keeps the priming, or loses more than it, cannot pass.
|
|
let lead = reference_lead(c.name) * c.channels;
|
|
let s = match major {
|
|
Some(9..) => snr(&buf.channels, &reference),
|
|
Some(_) => snr(&buf.channels, &reference[lead..]),
|
|
None => snr(&buf.channels, &reference).max(snr(&buf.channels, &reference[lead..])),
|
|
};
|
|
assert!(s >= c.min_snr, "{}: SNR {s:.1} dB < {}", c.name, c.min_snr);
|
|
let p = probe(&bytes, None).unwrap();
|
|
assert_eq!(
|
|
(p.container, p.codec, p.sample_rate, usize::from(p.channels), p.layout),
|
|
(c.container, c.codec, c.rate, c.channels, c.layout),
|
|
"{}: probe",
|
|
c.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_and_corrupted_files_never_panic() {
|
|
let ff = require_ffmpeg!();
|
|
for c in CASES {
|
|
let Some(path) = fixture(&ff, c) else { continue };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
for cut in [1, 7, 64, 500, bytes.len() / 3, bytes.len() / 2, bytes.len() - 1] {
|
|
let _ = decode(bytes.get(..cut).unwrap_or_default(), None);
|
|
let _ = probe(bytes.get(..cut).unwrap_or_default(), None);
|
|
}
|
|
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
|
for _ in 0..6 {
|
|
let mut m = bytes.clone();
|
|
for _ in 0..16 {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
let i = (seed % m.len() as u64) as usize;
|
|
m[i] ^= (seed >> 32) as u8 | 1;
|
|
}
|
|
let _ = decode(&m, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SNR (dB) of `ours` against `reference`, both planar, over the frames both hold.
|
|
fn snr_planar(ours: &[Vec<f32>], reference: &[Vec<f32>]) -> f64 {
|
|
let (mut s, mut e) = (0f64, 0f64);
|
|
for (o, r) in ours.iter().zip(reference) {
|
|
for (&a, &b) in o.iter().zip(r) {
|
|
s += f64::from(b) * f64::from(b);
|
|
e += f64::from(a - b) * f64::from(a - b);
|
|
}
|
|
}
|
|
if e == 0.0 { f64::INFINITY } else { 10.0 * (s / e).log10() }
|
|
}
|
|
|
|
/// The streaming decoder gives the same audio as `decode`, block by block, and a seek lands on the
|
|
/// exact frame: what follows matches `decode` from there, bit for bit.
|
|
#[test]
|
|
fn streaming_and_seeking_match_the_whole_file_decode() {
|
|
use craft_audio::{DecodeOptions, Decoder};
|
|
let ff = require_ffmpeg!();
|
|
for c in CASES {
|
|
let Some(path) = fixture(&ff, c) else { continue };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (_, whole) = decode(&bytes, None).unwrap();
|
|
let mut d = Decoder::from_bytes(&bytes, None, &DecodeOptions::default()).unwrap();
|
|
let mut streamed: Vec<Vec<f32>> = vec![Vec::new(); whole.channels.len()];
|
|
while let Some(b) = d.next_block().unwrap() {
|
|
for (s, ch) in streamed.iter_mut().zip(b.channels) {
|
|
s.extend(ch);
|
|
}
|
|
}
|
|
assert_eq!(streamed, whole.channels, "{}: streamed blocks", c.name);
|
|
|
|
for target in [1, whole.frames() / 3, whole.frames() - 4800] {
|
|
assert_eq!(d.seek(target as u64).unwrap(), target as u64, "{}", c.name);
|
|
let mut after: Vec<Vec<f32>> = vec![Vec::new(); whole.channels.len()];
|
|
while let Some(b) = d.next_block().unwrap() {
|
|
for (s, ch) in after.iter_mut().zip(b.channels) {
|
|
s.extend(ch);
|
|
}
|
|
}
|
|
assert_eq!(after[0].len(), whole.frames() - target, "{}: frames after a seek to {target}", c.name);
|
|
let expected: Vec<Vec<f32>> = whole.channels.iter().map(|ch| ch[target..].to_vec()).collect();
|
|
let s = snr_planar(&after, &expected);
|
|
// Opus rebuilds its predictor state from the pre-roll. The rest are exact, AC-3 / E-AC-3
|
|
// too: their dither is seeded from each syncframe, and the pre-roll frame restores the
|
|
// overlap.
|
|
let bound = if c.codec == CodecId::Opus { c.min_snr.min(25.0) } else { f64::INFINITY };
|
|
assert!(s >= bound, "{}: SNR {s:.1} dB after a seek to {target}", c.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `TrackDecoder` on the packets craft-media's demuxer reads gives the audio `decode` presents:
|
|
/// the same samples once the container's priming is skipped.
|
|
#[test]
|
|
fn track_decoder_on_demuxed_packets_matches_decode() {
|
|
use craft_audio::TrackDecoder;
|
|
use craft_media::isobmff;
|
|
let ff = require_ffmpeg!();
|
|
for name in ["aac.m4a", "aac-5.1.m4a", "opus.mp4", "mp2.mp4", "pcm-s24be.mov"] {
|
|
let c = CASES.iter().find(|c| c.name == name).unwrap();
|
|
let Some(path) = fixture(&ff, c) else { continue };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (info, whole) = decode(&bytes, None).unwrap();
|
|
let file = isobmff::open(bytes.as_slice()).unwrap();
|
|
let track = file.track_of_kind(isobmff::TrackKind::Audio).unwrap();
|
|
let t = &file.tracks[track];
|
|
let params = t.codec_params();
|
|
let packets: Vec<Vec<u8>> = (0..t.samples.len()).map(|i| file.read_sample(bytes.as_slice(), track, i).unwrap()).collect();
|
|
let lookahead: Vec<&[u8]> = packets.iter().take(8).map(Vec::as_slice).collect();
|
|
let mut dec = TrackDecoder::new(¶ms, &lookahead).unwrap();
|
|
assert_eq!((dec.codec(), dec.sample_rate(), dec.layout()), (info.codec, info.sample_rate, info.layout), "{name}");
|
|
let mut out: Vec<Vec<f32>> = vec![Vec::new(); usize::from(dec.channels())];
|
|
for p in &packets {
|
|
for (o, ch) in out.iter_mut().zip(dec.decode(p).unwrap().channels) {
|
|
o.extend(ch);
|
|
}
|
|
}
|
|
// The edit list's media time is the priming `decode` skips.
|
|
let skip = t.edits.first().map_or(0, |e| (e.media_time.max(0) as u64 * u64::from(info.sample_rate) / u64::from(t.timescale)) as usize);
|
|
let presented: Vec<Vec<f32>> = out.iter().map(|ch| ch[skip..skip + whole.frames()].to_vec()).collect();
|
|
assert_eq!(presented, whole.channels, "{name}");
|
|
}
|
|
}
|
|
|
|
/// `tracks` lists every audio track, `DecodeOptions::track` picks one, and `max_samples` refuses
|
|
/// a longer decode.
|
|
#[test]
|
|
fn track_choice_and_the_sample_limit() {
|
|
use craft_audio::{AudioError, DecodeOptions, decode_with, tracks};
|
|
let ff = require_ffmpeg!();
|
|
for ext in ["mkv", "mp4"] {
|
|
let out = crate::testkit::fixtures_dir("routing").join(format!("two-tracks.{ext}"));
|
|
let Some(path) = crate::testkit::fixtures::generate(&out, |tmp| {
|
|
let tmp = tmp.to_string_lossy();
|
|
run(
|
|
&ff,
|
|
&[
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"sine=f=440:r=48000:d=2",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"aevalsrc=0.3*sin(2*PI*660*t)|0.3*sin(2*PI*880*t):s=44100:d=2",
|
|
"-map",
|
|
"0:a",
|
|
"-map",
|
|
"1:a",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"96k",
|
|
&tmp,
|
|
],
|
|
)
|
|
}) else {
|
|
continue;
|
|
};
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let list = tracks(&bytes, None).unwrap();
|
|
assert_eq!(list.len(), 2, "{ext}");
|
|
assert_eq!((list[0].codec, list[0].sample_rate, list[0].channels), (CodecId::Aac, Some(48_000), Some(1)), "{ext}");
|
|
assert_eq!((list[1].sample_rate, list[1].channels), (Some(44_100), Some(2)), "{ext}");
|
|
let (first, _) = decode_with(&bytes, None, &DecodeOptions::default()).unwrap();
|
|
let (second, buf) = decode_with(&bytes, None, &DecodeOptions::default().with_track(1)).unwrap();
|
|
assert_eq!((first.channels, first.sample_rate), (1, 48_000), "{ext}");
|
|
assert_eq!((second.channels, second.sample_rate, buf.frames()), (2, 44_100, 88_200), "{ext}");
|
|
let limited = decode_with(&bytes, None, &DecodeOptions::default().with_max_samples(1000));
|
|
assert!(matches!(limited, Err(AudioError::TooLarge(_))), "{ext}");
|
|
// Refused from the track list, without reading the file through the fallback.
|
|
let missing = decode_with(&bytes, None, &DecodeOptions::default().with_track(2));
|
|
assert!(matches!(&missing, Err(AudioError::Unsupported(m)) if m.contains("no audio track 2")), "{ext}: {missing:?}");
|
|
}
|
|
}
|
|
|
|
/// `Decoder::open` reads from a file without loading it.
|
|
#[test]
|
|
fn decoder_reads_from_a_file() {
|
|
use craft_audio::{DecodeOptions, Decoder};
|
|
let ff = require_ffmpeg!();
|
|
let c = CASES.iter().find(|c| c.name == "aac.m4a").unwrap();
|
|
let Some(path) = fixture(&ff, c) else { return };
|
|
let (_, whole) = decode(&std::fs::read(&path).unwrap(), None).unwrap();
|
|
let mut d = Decoder::open(std::fs::File::open(&path).unwrap(), None, &DecodeOptions::default()).unwrap();
|
|
assert!(d.length_known());
|
|
assert_eq!(d.info().frames, whole.frames() as u64);
|
|
let mut frames = 0;
|
|
while let Some(b) = d.next_block().unwrap() {
|
|
frames += b.frames();
|
|
}
|
|
assert_eq!(frames, whole.frames());
|
|
}
|
|
|
|
/// `fixture`'s AAC packets in a Matroska file with no block durations, like ffmpeg 6's,
|
|
/// and a `DURATION` tag for the track when `tag` is given.
|
|
fn aac_mkv_without_durations(bytes: &[u8], tag: Option<&str>) -> Vec<u8> {
|
|
use craft_media::matroska::{Demuxer, LacingMode, MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
|
let mut demux = Demuxer::from_slice(bytes).unwrap();
|
|
let track = demux.file().tracks[0].clone();
|
|
let mut spec = TrackSpec::new(TrackKind::Audio, track.codec_id.clone());
|
|
spec.codec_private = track.codec_private.clone();
|
|
spec.audio = Some((48_000.0, 2, None));
|
|
spec.codec_delay_ns = track.codec_delay_ns;
|
|
let opts = MuxOptions { lacing: LacingMode::None, ..MuxOptions::default() };
|
|
let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![spec], opts).unwrap();
|
|
while let Some(p) = demux.next_packet().unwrap() {
|
|
w.write_frame(0, p.pts_ns, true, &p.data, None).unwrap();
|
|
}
|
|
let mut out = w.finish().unwrap().into_inner();
|
|
let Some(tag) = tag else { return out };
|
|
|
|
fn el(id: &[u8], body: &[u8]) -> Vec<u8> {
|
|
assert!(body.len() < 127);
|
|
[id, &[0x80 | body.len() as u8], body].concat()
|
|
}
|
|
let uid = Demuxer::from_slice(&out).unwrap().file().tracks[0].uid.to_be_bytes();
|
|
let targets = el(&[0x63, 0xC0], &el(&[0x63, 0xC5], &uid));
|
|
let simple = el(&[0x67, 0xC8], &[el(&[0x45, 0xA3], b"DURATION"), el(&[0x44, 0x87], tag.as_bytes())].concat());
|
|
let tags = el(&[0x12, 0x54, 0xC3, 0x67], &el(&[0x73, 0x73], &[targets, simple].concat()));
|
|
out.extend_from_slice(&tags);
|
|
// The Segment's 8-byte size grows by the tags.
|
|
let at = out.windows(4).position(|w| w == [0x18, 0x53, 0x80, 0x67]).unwrap() + 4;
|
|
let size = u64::from_be_bytes(out[at..at + 8].try_into().unwrap()) + tags.len() as u64;
|
|
out[at..at + 8].copy_from_slice(&size.to_be_bytes());
|
|
out
|
|
}
|
|
|
|
/// A track with no block durations ends where its `DURATION` tag says, when that is inside the
|
|
/// last packet. Otherwise all the file's audio is presented.
|
|
#[test]
|
|
fn matroska_aac_without_block_durations_ends_at_its_duration_tag() {
|
|
let ff = require_ffmpeg!();
|
|
let case = CASES.iter().find(|c| c.name == "aac.mkv").unwrap();
|
|
let Some(path) = fixture(&ff, case) else { return };
|
|
let bytes = std::fs::read(path).unwrap();
|
|
let frames = |tag| {
|
|
let file = aac_mkv_without_durations(&bytes, tag);
|
|
let (info, buf) = decode(&file, None).unwrap();
|
|
assert_eq!(probe(&file, None).unwrap().frames, info.frames, "{tag:?}");
|
|
buf.frames()
|
|
};
|
|
// 5 s, plus the 21 ms of priming the stored timestamps count
|
|
assert_eq!(frames(Some("00:00:05.021000000")), 240_000);
|
|
// 236 packets less the priming
|
|
let all = 236 * 1024 - 1024;
|
|
assert_eq!(frames(None), all);
|
|
assert_eq!(frames(Some("00:00:03.000000000")), all, "ends before the last packet");
|
|
assert_eq!(frames(Some("00:00:09.000000000")), all, "ends after the last packet");
|
|
assert_eq!(frames(Some("not a duration")), all);
|
|
}
|
|
|
|
/// ADTS with channel configuration 0 takes its layout from the program config element in the stream.
|
|
#[test]
|
|
fn adts_with_a_program_config_element_decodes() {
|
|
let ff = require_ffmpeg!();
|
|
let c = Case {
|
|
name: "aac-pce.aac",
|
|
surround: true,
|
|
mono: false,
|
|
rate: 48_000,
|
|
args: &["-c:a", "aac", "-b:a", "384k", "-aac_pns", "0", "-aac_pce", "1"],
|
|
container: Container::Adts,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::unordered(6),
|
|
channels: 6,
|
|
frames: None,
|
|
min_snr: 100.0,
|
|
};
|
|
let Some(path) = fixture(&ff, &c) else { return };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
assert_eq!(bytes[3] >> 6 | (bytes[2] & 1) << 2, 0, "the fixture signals its layout in a PCE");
|
|
let (info, buf) = decode(&bytes, None).unwrap();
|
|
assert_eq!((info.container, info.codec, buf.channels.len()), (c.container, c.codec, c.channels));
|
|
// A program config element that lists a centre, a front pair and then the elements of 5.1 has
|
|
// 5.1's speakers, in WAV order like ffmpeg's decode; any other list keeps the decoded order,
|
|
// which ffmpeg versions write differently.
|
|
let ordered = info.layout.is_ordered();
|
|
assert_eq!(info.layout, if ordered { ChannelLayout::SURROUND_5_1_SIDE } else { c.layout });
|
|
assert_eq!(buf.frames(), reference(&ff, &path).len() / c.channels);
|
|
// Each of our channels must match a different one of ffmpeg's: the same one when the layout is
|
|
// ordered.
|
|
let refr = reference(&ff, &path);
|
|
let mut matched = Vec::new();
|
|
for plane in &buf.channels {
|
|
let (best, s) = (0..c.channels)
|
|
.map(|r| {
|
|
let other: Vec<f32> = refr.iter().skip(r).step_by(c.channels).copied().collect();
|
|
(r, snr(std::slice::from_ref(plane), &other))
|
|
})
|
|
.max_by(|a, b| a.1.total_cmp(&b.1))
|
|
.unwrap();
|
|
assert!(s >= c.min_snr, "channel {} matches ffmpeg's {best} at only {s:.1} dB", matched.len());
|
|
matched.push(best);
|
|
}
|
|
if !ordered {
|
|
matched.sort_unstable();
|
|
}
|
|
assert_eq!(matched, (0..c.channels).collect::<Vec<_>>());
|
|
}
|
|
|
|
/// `probe` and `decode` agree on an MP4's length when no single edit gives it: the samples'
|
|
/// durations end the track. ffmpeg 9 does the same, but earlier versions present the whole last
|
|
/// packet, so the length is not compared with its decode.
|
|
#[test]
|
|
fn mp4_without_an_edit_list_ends_with_its_sample_durations() {
|
|
let ff = require_ffmpeg!();
|
|
let c = Case {
|
|
name: "aac-no-edits.m4a",
|
|
surround: false,
|
|
mono: false,
|
|
rate: 44_100,
|
|
args: &["-c:a", "aac", "-b:a", "128k", "-aac_pns", "0", "-use_editlist", "0"],
|
|
container: Container::Mp4,
|
|
codec: CodecId::Aac,
|
|
layout: ChannelLayout::STEREO,
|
|
channels: 2,
|
|
frames: None,
|
|
min_snr: 100.0,
|
|
};
|
|
let Some(path) = fixture(&ff, &c) else { return };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let (info, buf) = decode(&bytes, None).unwrap();
|
|
assert_eq!(probe(&bytes, None).unwrap().frames, buf.frames() as u64);
|
|
assert_eq!(info.frames, buf.frames() as u64);
|
|
// 5 s, and 1024 frames of priming with no edit list to skip them.
|
|
assert_eq!(buf.frames(), 5 * 44_100 + 1024);
|
|
}
|
|
|
|
/// The big-endian `u32` at each of `offsets` after the first `box_name` in `file`, changed by `edit`.
|
|
fn patched(file: &[u8], box_name: &[u8; 4], offsets: &[usize], edit: impl Fn(u32) -> u32) -> Vec<u8> {
|
|
let mut out = file.to_vec();
|
|
let at = file.windows(4).position(|w| w == box_name).unwrap();
|
|
for &o in offsets {
|
|
let p = at + o;
|
|
let v: [u8; 4] = out[p..p + 4].try_into().unwrap();
|
|
out[p..p + 4].copy_from_slice(&edit(u32::from_be_bytes(v)).to_be_bytes());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Wrong sample durations or edits don't cut audio or disagree with `probe`: durations only trim the
|
|
/// last packet, and an edit can't outlast the media.
|
|
#[test]
|
|
fn mp4_with_wrong_durations_or_edits_presents_its_audio() {
|
|
let ff = require_ffmpeg!();
|
|
let c = CASES.iter().find(|c| c.name == "aac.m4a").unwrap();
|
|
let Some(path) = fixture(&ff, c) else { return };
|
|
let bytes = std::fs::read(path).unwrap();
|
|
let (_, whole) = decode(&bytes, None).unwrap();
|
|
// From the name's start: the name (4), version and flags (4), entries (4), then stts's first
|
|
// run's count (4) and delta, or elst v0's first duration.
|
|
let stts_delta = 4 + 4 + 4 + 4;
|
|
let elst_duration = 4 + 4 + 4;
|
|
for (name, file) in [
|
|
("durations halved", patched(&bytes, b"stts", &[stts_delta], |v| v / 2)),
|
|
("durations of one", patched(&bytes, b"stts", &[stts_delta], |_| 1)),
|
|
("durations doubled", patched(&bytes, b"stts", &[stts_delta], |v| v * 2)),
|
|
("durations of zero", patched(&bytes, b"stts", &[stts_delta], |_| 0)),
|
|
("last delta longer than a packet", patched(&bytes, b"stts", &[stts_delta + 8], |_| 1500)),
|
|
] {
|
|
assert_ne!(file, bytes, "{name}");
|
|
let (info, buf) = decode(&file, None).unwrap();
|
|
assert!(buf.frames().abs_diff(whole.frames()) <= 1024, "{name}: {} frames", buf.frames());
|
|
assert_eq!((probe(&file, None).unwrap().frames, info.frames), (buf.frames() as u64, buf.frames() as u64), "{name}");
|
|
// A seek lands on the same audio: a packet's place comes from its index, not the durations.
|
|
let mut d = craft_audio::Decoder::from_bytes(&file, None, &craft_audio::DecodeOptions::default()).unwrap();
|
|
assert_eq!(d.seek(50_000).unwrap(), 50_000, "{name}");
|
|
let block = d.next_block().unwrap().unwrap();
|
|
for (a, b) in block.channels[0].iter().zip(&buf.channels[0][50_000..]) {
|
|
assert!((a - b).abs() < 1e-4, "{name}: seek");
|
|
}
|
|
}
|
|
let file = patched(&bytes, b"elst", &[elst_duration], |v| v * 2);
|
|
let (info, buf) = decode(&file, None).unwrap();
|
|
assert!(buf.frames() < whole.frames() + 2048, "edit doubled: {} frames", buf.frames());
|
|
assert_eq!((probe(&file, None).unwrap().frames, info.frames), (buf.frames() as u64, buf.frames() as u64), "edit doubled");
|
|
}
|
|
|
|
/// An empty edit before the media edit (QuickTime's and GPAC's delayed audio, ffmpeg's
|
|
/// `-itsoffset`) still has the media edit applied: its priming skip and its end, as ffmpeg's
|
|
/// decode does. ffmpeg writes such a media edit with no skip, so the test sets one.
|
|
#[test]
|
|
fn mp4_with_an_empty_edit_before_its_media_edit_applies_the_media_edit() {
|
|
let ff = require_ffmpeg!();
|
|
let dir = crate::testkit::fixtures_dir("routing");
|
|
let out = dir.join("aac-itsoffset.m4a");
|
|
let input = format!("{MONO}:s=44100");
|
|
let made = crate::testkit::fixtures::generate(&out, |tmp| {
|
|
let tmp = tmp.to_string_lossy();
|
|
run(ff.as_path(), &["-itsoffset", "0.5", "-f", "lavfi", "-i", &input, "-t", "5", "-c:a", "aac", "-aac_pns", "0", &tmp])
|
|
});
|
|
assert!(made.is_some() || !craft_media_testkit::oracles_required(), "ffmpeg could not write aac-itsoffset.m4a");
|
|
let Some(path) = made else { return };
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
// elst v0 from the name's start: the name (4), version and flags (4), entries (4), then 12
|
|
// bytes per edit: duration, media time, rate.
|
|
let elst = bytes.windows(4).position(|w| w == b"elst").unwrap();
|
|
assert_eq!((bytes[elst + 4], u32::from_be_bytes(bytes[elst + 8..elst + 12].try_into().unwrap())), (0, 2), "an empty edit and a media edit");
|
|
assert_eq!(i32::from_be_bytes(bytes[elst + 12 + 4..elst + 12 + 8].try_into().unwrap()), -1, "the first edit is empty");
|
|
let media_time = elst + 12 + 12 + 4;
|
|
let mut file = bytes.clone();
|
|
file[media_time..media_time + 4].copy_from_slice(&1024i32.to_be_bytes());
|
|
let patched = dir.join("aac-itsoffset-priming.m4a");
|
|
std::fs::write(&patched, &file).unwrap();
|
|
let reference = reference(&ff, &patched);
|
|
let (info, buf) = decode(&file, None).unwrap();
|
|
assert_eq!(buf.frames(), reference.len(), "presented frames");
|
|
assert_eq!((probe(&file, None).unwrap().frames, info.frames), (buf.frames() as u64, buf.frames() as u64));
|
|
let s = snr(&buf.channels, &reference);
|
|
assert!(s >= 100.0, "SNR {s:.1} dB: the priming skip is not applied");
|
|
}
|