filmcraft/crates/codecs/tests/ogg_oracle.rs

227 lines
8.5 KiB
Rust

//! Ogg Opus / Vorbis against ffmpeg: ffmpeg (libopus, its Vorbis encoder) writes the files and
//! decodes them as the oracle. Length must match exactly (pre-skip and end trimming), the signal
//! must match (libopus: SNR ≥ 50 dB on CELT music, ≥ 10 dB on SILK/hybrid speech, as in
//! `filmcraft-opus`'s own oracle; Vorbis: SNR ≥ 60 dB), and any random window must equal the
//! continuous decode (granule-position seeking with pre-roll).
mod common;
use std::path::{Path, PathBuf};
use common::*;
use filmcraft_codecs::OggSource;
use filmcraft_media::{MediaKind, MediaSource};
const MUSIC: &[&str] = &[
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000",
"-f",
"lavfi",
"-i",
"anoisesrc=color=pink:sample_rate=48000:amplitude=0.2:seed=3",
"-filter_complex",
"[0:a][1:a]amerge=inputs=2[a]",
"-map",
"[a]",
];
fn spec(name: &str) -> Vec<&'static str> {
match name {
"ogg_opus_music.opus" => [MUSIC, &["-t", "3", "-c:a", "libopus", "-b:a", "160k", "-application", "audio"]].concat(),
"ogg_opus_trim.ogg" => [MUSIC, &["-t", "1.2345", "-c:a", "libopus", "-b:a", "96k", "-frame_duration", "10"]].concat(),
"ogg_opus_speech.opus" => {
vec![
"-f",
"lavfi",
"-i",
"anoisesrc=color=brown:sample_rate=16000:amplitude=0.3:seed=5",
"-t",
"2",
"-ac",
"1",
"-c:a",
"libopus",
"-b:a",
"12k",
"-application",
"voip",
]
}
// the same signal on both channels: ffmpeg's experimental Vorbis encoder (stereo only)
// codes an independent second channel so poorly (1.7 dB SNR against the source, with its
// own decoder) that decoders legitimately disagree on it
"ogg_vorbis.ogg" => vec![
"-f",
"lavfi",
"-i",
"aevalsrc=0.5*sin(2*PI*440*t)+0.2*sin(2*PI*1234*t)*sin(2*PI*3*t):s=44100",
"-t",
"2",
"-ac",
"2",
"-c:a",
"vorbis",
"-strict",
"-2",
],
other => panic!("unknown fixture {other}"),
}
}
const ALL: &[&str] = &["ogg_opus_music.opus", "ogg_opus_trim.ogg", "ogg_opus_speech.opus", "ogg_vorbis.ogg"];
fn make(ff: &Path, name: &str) -> PathBuf {
fixture(ff, name, &spec(name)).unwrap_or_else(|| panic!("could not generate {name}"))
}
fn snr_db(reference: &[f32], ours: &[f32]) -> f64 {
let (mut s, mut n) = (0f64, 0f64);
for (r, o) in reference.iter().zip(ours) {
s += (*r as f64).powi(2);
n += (*r as f64 - *o as f64).powi(2);
}
if n == 0.0 { f64::INFINITY } else { 10.0 * (s / n).log10() }
}
/// (our continuous decode interleaved, ffmpeg's decode interleaved, channels, rate)
fn decode_both(ff: &Path, file: &Path, decoder: &[&str]) -> (OggSource, Vec<f32>, Vec<f32>, usize, u32) {
let src = OggSource::open(file.file_name().unwrap().to_str().unwrap(), bytes(file)).unwrap();
let a = src.info().audio().cloned().unwrap();
let ch = a.channels as usize;
let mut args: Vec<&str> = decoder.to_vec();
args.extend_from_slice(&["-i", file.to_str().unwrap(), "-map", "0:a:0", "-f", "f32le", "-"]);
let want: Vec<f32> = ffmpeg_out(ff, &args).as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let n = want.len() / ch;
let frames = src.info().duration.to_units_floor(a.sample_rate as i64) as usize;
assert_eq!(frames, n, "{}: our length = ffmpeg's ({} samples)", file.display(), n);
let buf = src.audio(0, n, a.sample_rate).unwrap();
let mut ours = Vec::with_capacity(n * ch);
for i in 0..n {
for c in 0..ch {
ours.push(buf.channels[c][i]);
}
}
(src, ours, want, ch, a.sample_rate)
}
/// Random windows on a fresh source equal the continuous decode.
fn check_seeks(file: &Path, ours: &[f32], ch: usize, rate: u32, tol: f32) {
let src = OggSource::open("seek", bytes(file)).unwrap();
let n = ours.len() / ch;
let mut rng = Rng(0xA5A5_1234);
let mut worst = 0f32;
for _ in 0..25 {
let s = rng.below(n as u64 - 10) as usize;
let len = 1 + rng.below(4000) as usize;
let got = src.audio(s as i64, len, rate).unwrap();
for k in 0..len.min(n - s) {
for c in 0..ch {
worst = worst.max((got.channels[c][k] - ours[(s + k) * ch + c]).abs());
}
}
}
assert!(worst <= tol, "{}: seek differs from continuous decode by {worst}", file.display());
eprintln!("{}: seeks within {worst:e} of the continuous decode", file.display());
}
#[test]
fn opus_celt_music_matches_libopus() {
let ff = filmcraft_testkit::require_ffmpeg!();
let f = make(&ff, "ogg_opus_music.opus");
let (src, ours, want, ch, rate) = decode_both(&ff, &f, &["-c:a", "libopus"]);
assert_eq!(src.info().container, "Ogg");
assert_eq!(src.info().kind, MediaKind::AudioOnly);
assert_eq!((rate, ch), (48_000, 2));
assert_eq!(src.info().audio().unwrap().codec, "Opus");
let snr = snr_db(&want, &ours);
eprintln!("{}: {} samples, SNR {snr:.1} dB vs libopus", f.display(), want.len() / ch);
assert!(snr >= 50.0, "SNR {snr:.1} dB");
check_seeks(&f, &ours, ch, rate, 2e-3);
}
#[test]
fn opus_end_trimming_and_10ms_frames() {
let ff = filmcraft_testkit::require_ffmpeg!();
let f = make(&ff, "ogg_opus_trim.ogg");
let (src, ours, want, ch, rate) = decode_both(&ff, &f, &["-c:a", "libopus"]);
// 1.2345 s is not a whole number of 10 ms packets: the last page's granule trims the end
let t = filmcraft_ogg::OpusTiming::of(&src.file().streams[0], filmcraft_opus::OpusHead::parse(&src.file().streams[0].headers[0]).unwrap().pre_skip as u32);
let decoded: i64 = t.durations.iter().map(|&d| d as i64).sum::<i64>() - t.pre_skip as i64;
assert!(t.total < decoded, "end trimmed: {} < {}", t.total, decoded);
assert_eq!(t.total, 59_256, "1.2345 s at 48 kHz");
let snr = snr_db(&want, &ours);
assert!(snr >= 50.0, "SNR {snr:.1} dB");
check_seeks(&f, &ours, ch, rate, 2e-3);
}
#[test]
fn opus_silk_speech_matches_libopus() {
let ff = filmcraft_testkit::require_ffmpeg!();
let f = make(&ff, "ogg_opus_speech.opus");
let (_src, ours, want, ch, rate) = decode_both(&ff, &f, &["-c:a", "libopus"]);
assert_eq!(ch, 1);
let snr = snr_db(&want, &ours);
eprintln!("{}: SNR {snr:.1} dB vs libopus", f.display());
assert!(snr >= 10.0, "SNR {snr:.1} dB");
check_seeks(&f, &ours, ch, rate, 2e-2);
}
#[test]
fn vorbis_matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let f = make(&ff, "ogg_vorbis.ogg");
let (src, ours, want, ch, rate) = decode_both(&ff, &f, &[]);
assert_eq!(src.info().audio().unwrap().codec, "Vorbis");
assert_eq!((rate, ch), (44_100, 2));
let snr = snr_db(&want, &ours);
eprintln!("{}: SNR {snr:.1} dB vs ffmpeg's Vorbis decoder", f.display());
assert!(snr >= 60.0, "SNR {snr:.1} dB");
check_seeks(&f, &ours, ch, rate, 0.0);
}
#[test]
fn opened_through_open_bytes_and_robust_to_damage() {
let ff = filmcraft_testkit::require_ffmpeg!();
let f = make(&ff, "ogg_opus_music.opus");
let s = filmcraft_codecs::open_bytes("x.opus", bytes(&f)).unwrap();
assert_eq!(s.info().container, "Ogg");
let full = std::fs::read(&f).unwrap();
let mut rng = Rng(42);
for k in 1..12 {
let cut = full.len() * k / 12;
if let Ok(s) = OggSource::open("cut", full[..cut].to_vec().into()) {
let n = s.info().duration.to_units_floor(48_000);
let _ = s.audio(rng.below(n.max(1) as u64) as i64, 5000, 48_000).unwrap();
}
}
for _ in 0..40 {
let mut g = full.clone();
for _ in 0..6 {
let at = rng.below(g.len() as u64) as usize;
g[at] = rng.below(256) as u8;
}
if let Ok(s) = OggSource::open("bad", g.into()) {
let _ = s.audio(0, 48_000, 48_000);
let _ = s.audio(30_000, 4_000, 44_100);
}
}
}
/// `cargo xtask fixtures codecs`: build every Ogg fixture.
#[test]
#[ignore]
fn generate_fixtures() {
let Some(ff) = filmcraft_testkit::ffmpeg() else {
for n in ALL {
filmcraft_testkit::fixtures::report(n, filmcraft_testkit::fixtures::Status::Skipped);
}
return;
};
for n in ALL {
let out = dir().join(n);
filmcraft_testkit::fixtures::generate_and_report(n, std::slice::from_ref(&out), || fixture(&ff, n, &spec(n)));
}
}