filmcraft/crates/codecs/tests/eac3_oracle.rs

86 lines
4.5 KiB
Rust

//! E-AC-3 (Dolby Digital Plus) audio in MP4, Matroska and MPEG-TS against ffmpeg: ffmpeg encodes
//! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific
//! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned.
mod common;
use common::*;
const STEREO: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000";
const SURROUND: &str =
"aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1";
/// Worst channel SNR (dB) of `ours` (planar) against ffmpeg's interleaved `want`, with ours
/// shifted by `lag` samples, over the samples both cover past the first 1000.
fn snr(ours: &[Vec<f32>], want: &[f32], ch: usize, lag: i64) -> f64 {
let n = want.len() / ch;
(0..ch)
.map(|c| {
let (mut sig, mut err) = (0f64, 0f64);
for i in 1000..n - 1000 {
let Some(&a) = ours[c].get((i as i64 + lag) as usize) else { continue };
let (a, b) = (a as f64, want[i * ch + c] as f64);
sig += b * b;
err += (a - b) * (a - b);
}
10.0 * (sig / err.max(1e-30)).log10()
})
.fold(f64::INFINITY, f64::min)
}
#[test]
fn eac3_in_mp4_matroska_and_transport_streams() {
let ff = filmcraft_testkit::require_ffmpeg!();
let cases: [(&str, &str, u32, &[&str]); 4] = [
("eac3_51.mp4", SURROUND, 6, &["-b:a", "384k"]),
("eac3_51.mkv", SURROUND, 6, &["-b:a", "384k"]),
("eac3_stereo.mov", STEREO, 2, &["-b:a", "192k"]),
("eac3_stereo.ts", STEREO, 2, &["-b:a", "192k"]),
];
for (name, input, channels, enc) in cases {
let args: Vec<&str> = ["-f", "lavfi", "-i", input, "-t", "2", "-c:a", "eac3"].into_iter().chain(enc.iter().copied()).collect();
let f = fixture(&ff, name, &args).unwrap_or_else(|| panic!("could not write {name}"));
let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
let a = src.info().audio().cloned().unwrap();
assert_eq!((a.sample_rate, a.channels), (48_000, channels), "{name}: {}", a.codec);
let want = ffmpeg_audio_f32(&ff, &f, &[]);
let ch = channels as usize;
let n = want.len() / ch;
// MPEG-TS audio starts at its first PTS relative to the presentation start
let start = match name.ends_with(".ts") {
true => filmcraft_codecs::MpegSource::open(name, bytes(&f)).unwrap().audio_extent().map_or(0, |e| e.0),
false => 0,
};
let ours = src.audio(start, n, 48_000).unwrap().channels;
assert_eq!(ours.len(), ch, "{name}");
// Matroska stores the encoder's 256-sample priming as CodecDelay, which we trim exactly
// (checked to the sample against the encoded signal in `matroska_audio_oracle`). Whether
// ffmpeg's CLI trims it too depends on its version (9.x does; 2023-24 builds keep the
// priming samples), so for .mkv its decode may start 256 samples earlier than ours.
// Elsewhere the samples line up exactly, whatever the version.
let lags: Vec<i64> = match name.ends_with(".mkv") {
true => (-32..=32).chain(-256 - 32..=-256 + 32).collect(),
false => (-32..=32).collect(),
};
let (worst, lag) = lags.into_iter().map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a });
let ok = if name.ends_with(".mkv") { lag == 0 || lag == -256 } else { lag == 0 };
assert!(ok, "{name}: decode is {lag} samples off ffmpeg's");
// a window read on a fresh source (random access) gives the same samples
let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
let at = n / 3;
let win = fresh.audio(start + at as i64, 4800, 48_000).unwrap().channels;
let mut seek_err = 0f32;
for c in 0..ch {
for (k, v) in win[c].iter().enumerate() {
seek_err = seek_err.max((v - ours[c][at + k]).abs());
}
}
println!(
"{name}: {} ({} Hz, {ch} ch), worst channel SNR vs ffmpeg {worst:.1} dB (lag {lag}), random access max diff {seek_err:.1e}",
a.codec, a.sample_rate
);
assert!(worst >= 50.0, "{name}: SNR {worst:.1} dB");
assert!(seek_err <= 1e-6, "{name}: random-access audio differs by {seek_err}");
}
}