filmcraft/crates/aac/tests/encoder_oracle.rs

273 lines
8.8 KiB
Rust

//! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent).
mod common;
use common::*;
use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape};
struct Case {
name: &'static str,
rate: u32,
signal: fn(u32, usize, usize) -> Vec<Vec<f32>>,
channels: usize,
}
fn mono(v: Vec<f32>, ch: usize) -> Vec<Vec<f32>> {
(0..ch).map(|c| if c == 0 { v.clone() } else { v.iter().map(|x| x * (0.8 - 0.1 * c as f32)).collect() }).collect()
}
fn sig_sweep(r: u32, n: usize, ch: usize) -> Vec<Vec<f32>> {
mono(sweep(r, n, 0.5), ch)
}
fn sig_noise(_r: u32, n: usize, ch: usize) -> Vec<Vec<f32>> {
(0..ch).map(|c| noise(n, 0.3, 7 + c as u64)).collect()
}
fn sig_speech(r: u32, n: usize, ch: usize) -> Vec<Vec<f32>> {
mono(speech(r, n, 3), ch)
}
fn sig_chords(r: u32, n: usize, ch: usize) -> Vec<Vec<f32>> {
(0..ch).map(|c| chords(r, n, 0.002 * c as f32)).collect()
}
fn sig_clicks(r: u32, n: usize, ch: usize) -> Vec<Vec<f32>> {
mono(clicks(r, n), ch)
}
struct Row {
label: String,
target: u32,
actual: f64,
snr: f64,
bw_snr: f64,
bw: u32,
seg: f64,
peak: f32,
shorts: u64,
ours_vs_ff: f32,
}
fn run(case: &Case, bitrate: u32, cfg_mod: impl Fn(&mut EncoderConfig)) -> Option<Row> {
let ff = ffmpeg()?;
let secs = 2.0;
let n = (case.rate as f32 * secs) as usize + 333;
let input = (case.signal)(case.rate, n, case.channels);
let mut cfg = EncoderConfig::cbr(case.rate, case.channels, bitrate);
cfg_mod(&mut cfg);
let enc = encode(cfg.clone(), &input);
let tag = match cfg.mode {
BitrateMode::Cbr(b) => format!("{b}"),
BitrateMode::Vbr(q) => format!("q{q}"),
};
let path = fixtures().join(format!("enc_{}_{}ch_{}_{}.aac", case.name, case.channels, case.rate, tag));
std::fs::write(&path, &enc.adts).unwrap();
let (dec, err) = ffmpeg_decode(&ff, &path, case.channels);
assert!(err.trim().is_empty(), "ffmpeg reported errors for {}: {err}", path.display());
assert_eq!(dec[0].len(), enc.frames * 1024);
assert!(dec[0].len() >= n + 1024, "decoded stream too short");
// ffmpeg reorders channels to its native layouts: map each of our (AAC element order) decoded
// channels to the matching ffmpeg channel.
let ours = our_decode(&enc.adts);
let mut taken = vec![false; case.channels];
let mut snr_sum = 0.0;
let mut seg_sum = 0.0;
let mut bw_sum = 0.0;
let mut peak = 0f32;
let mut ours_vs_ff = 0f32;
let mut counted = 0;
for c in 0..case.channels {
let (j, err) = (0..case.channels).filter(|&j| !taken[j]).map(|j| (j, max_abs_diff(&ours[c], &dec[j]))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap();
taken[j] = true;
ours_vs_ff = ours_vs_ff.max(err);
let d = &dec[j][1024..1024 + n];
peak = peak.max(d.iter().fold(0f32, |m, v| m.max(v.abs())));
if case.channels >= 6 && c == case.channels - 1 {
continue; // LFE is band-limited to 240 Hz by design
}
snr_sum += snr(&input[c], d);
let fc = enc.bandwidth as f32 * 0.97;
bw_sum += snr(&lowpass(&input[c], fc, case.rate as f32), &lowpass(d, fc, case.rate as f32));
seg_sum += seg_snr(&input[c], d);
counted += 1;
}
let actual = enc.stats.bitrate(case.rate);
Some(Row {
label: format!("{:<7} {}ch {:>5}", case.name, case.channels, case.rate),
target: if matches!(cfg.mode, BitrateMode::Vbr(_)) { 0 } else { bitrate },
actual,
snr: snr_sum / counted as f64,
bw_snr: bw_sum / counted as f64,
bw: enc.bandwidth,
seg: seg_sum / counted as f64,
peak,
shorts: enc.stats.short_blocks,
ours_vs_ff,
})
}
fn print(rows: &[Row]) {
println!(
"{:<22} {:>8} {:>8} {:>7} {:>6} {:>7} {:>7} {:>7} {:>6} {:>6} {:>9}",
"signal", "target", "actual", "dev%", "bw", "SNR", "bwSNR", "segSNR", "peak", "short", "us-vs-ff"
);
for r in rows {
let (target, dev) = if r.target == 0 {
("vbr".to_string(), "-".to_string())
} else {
(r.target.to_string(), format!("{:+.2}", (r.actual / r.target as f64 - 1.0) * 100.0))
};
println!(
"{:<22} {:>8} {:>8.0} {:>7} {:>6} {:>7.2} {:>7.2} {:>7.2} {:>6.3} {:>6} {:>9.2e}",
r.label, target, r.actual, dev, r.bw, r.snr, r.bw_snr, r.seg, r.peak, r.shorts, r.ours_vs_ff
);
}
}
fn check(r: &Row, min_seg: f64) {
let dev = (r.actual / r.target as f64 - 1.0).abs();
assert!(dev < 0.05, "{}: bitrate {} vs target {}", r.label, r.actual, r.target);
assert!(r.peak < 1.0, "{}: clipping (peak {})", r.label, r.peak);
assert!(r.seg > min_seg, "{}: segmental SNR {} too low", r.label, r.seg);
assert!(r.ours_vs_ff < 1e-3, "{}: our decoder differs from ffmpeg by {}", r.label, r.ours_vs_ff);
}
#[test]
fn stereo_matrix_44k_48k() {
if ffmpeg().is_none() {
eprintln!("ffmpeg not found; skipping");
return;
}
let cases = [
Case { name: "sweep", rate: 44100, signal: sig_sweep, channels: 2 },
Case { name: "noise", rate: 44100, signal: sig_noise, channels: 2 },
Case { name: "speech", rate: 48000, signal: sig_speech, channels: 2 },
Case { name: "chords", rate: 44100, signal: sig_chords, channels: 2 },
Case { name: "clicks", rate: 48000, signal: sig_clicks, channels: 2 },
];
let mut rows = Vec::new();
for c in &cases {
for br in [64_000, 128_000, 192_000, 320_000] {
let r = run(c, br, |_| {}).unwrap();
rows.push(r);
}
}
print(&rows);
for r in &rows {
check(
r,
if r.label.starts_with("noise") {
0.5
} else if r.target >= 128_000 {
8.0
} else {
3.0
},
);
}
}
#[test]
fn mono_and_surround() {
if ffmpeg().is_none() {
eprintln!("ffmpeg not found; skipping");
return;
}
let mut rows = Vec::new();
for (c, br) in [
(Case { name: "speech", rate: 44100, signal: sig_speech, channels: 1 }, 64_000),
(Case { name: "chords", rate: 48000, signal: sig_chords, channels: 1 }, 96_000),
(Case { name: "clicks", rate: 44100, signal: sig_clicks, channels: 1 }, 128_000),
(Case { name: "chords", rate: 48000, signal: sig_chords, channels: 6 }, 320_000),
(Case { name: "noise", rate: 48000, signal: sig_noise, channels: 6 }, 384_000),
] {
rows.push(run(&c, br, |_| {}).unwrap());
}
print(&rows);
for r in &rows {
check(r, 3.0);
}
}
#[test]
fn kbd_windows_no_tns_no_ms() {
if ffmpeg().is_none() {
return;
}
let c = Case { name: "kbd", rate: 44100, signal: sig_clicks, channels: 2 };
let r = run(&c, 128_000, |cfg| {
cfg.window_shape = WindowShape::Kbd;
cfg.tns = false;
cfg.ms_stereo = false;
})
.unwrap();
print(std::slice::from_ref(&r));
check(&r, 5.0);
}
#[test]
fn sample_rates_8k_to_96k() {
if ffmpeg().is_none() {
return;
}
let mut rows = Vec::new();
for (rate, br) in [
(8000u32, 24_000u32),
(11025, 32_000),
(12000, 32_000),
(16000, 48_000),
(22050, 64_000),
(24000, 64_000),
(32000, 96_000),
(64000, 192_000),
(88200, 256_000),
(96000, 256_000),
] {
let c = Case { name: "chords", rate, signal: sig_chords, channels: 2 };
rows.push(run(&c, br, |_| {}).unwrap());
}
print(&rows);
for r in &rows {
check(r, 5.0);
}
}
#[test]
fn channel_layouts_3_to_8() {
if ffmpeg().is_none() {
return;
}
let mut rows = Vec::new();
for (ch, br) in [(3usize, 192_000u32), (4, 256_000), (5, 320_000), (7, 384_000), (8, 448_000)] {
let c = Case { name: "chords", rate: 48000, signal: sig_chords, channels: ch };
rows.push(run(&c, br, |_| {}).unwrap());
}
print(&rows);
for r in &rows {
check(r, 5.0);
}
}
#[test]
fn vbr_quality_scale() {
if ffmpeg().is_none() {
return;
}
let mut rows = Vec::new();
for sig in [sig_chords as fn(u32, usize, usize) -> Vec<Vec<f32>>, sig_speech] {
for q in [1.0f32, 2.0, 3.0, 4.0, 5.0] {
let c = Case { name: "vbr", rate: 44100, signal: sig, channels: 2 };
let mut r = run(&c, 1, |cfg| cfg.mode = BitrateMode::Vbr(q)).unwrap();
r.label = format!("vbr q{q} 2ch 44100");
rows.push(r);
}
}
print(&rows);
for pair in rows.chunks(5) {
for w in pair.windows(2) {
assert!(w[1].actual > w[0].actual, "VBR bitrate must grow with quality");
assert!(w[1].seg > w[0].seg - 0.5, "VBR quality must grow with quality");
}
for r in pair {
assert!(r.peak < 1.0 && r.ours_vs_ff < 1e-3);
}
}
}