mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* craft-audio: Opus decoder FilmCraft's `filmcraft-opus` decoder as `craft_audio::opus` behind `opus-decode`: SILK, hybrid and CELT, every frame size, PLC and FEC, multistream (`Decoder`) and single stream (`StreamDecoder`), 8-48 kHz output. - `TrackDecoder` decodes Opus; mono and stereo without an `OpusHead` (RTP, MPEG-TS) get a default one. Lost packets use Opus's own concealment. - The native read path takes Ogg Opus (pre-skip and end trimming per RFC 7845), and Opus in Matroska/WebM and MP4, where packet starts after a seek come from the packets' own lengths. The RFC 8251 conformance test runs all 36 vectors. `scripts/fetch-opus-vectors.sh` downloads them (SHA-256 checked) and the CI test job caches them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, round 1 (#445) - CELT concealment keeps its pitch in its own field. It reused postfilter_period_old, which the next good frame's post-filter cross-fades from. - Matroska Opus seeks: a failed packet read no longer caches a wrong start; a one-byte packet is read as one byte. - A malformed OpusHead in Ogg is reported as malformed instead of falling back. - The libopus oracle tests skip through the testkit, so CRAFT_MEDIA_REQUIRE_ORACLES fails them. - Decoder::reset documents that it re-arms pre-skip trimming. - fetch-opus-vectors.sh retries its size probe. - CI: the vectors are fetched after ffmpeg is installed, and apt's timeout runs as root so a stalled apt-get is killed and frees the dpkg lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, round 2 (#445) - CELT: the noise-based concealment sets skip_plc, as libopus does, so pitch-based concealment waits for two good frames in a row instead of extending history that holds noise. - SILK: decode_only_middle is per call, as in libopus. A lost or mono frame no longer reuses the mid-only flag from an earlier frame. - A damaged Opus packet is concealed at its own length (from its TOC and framing) rather than the previous packet's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
166 lines
6.9 KiB
Rust
166 lines
6.9 KiB
Rust
//! RFC 6716 / RFC 8251 conformance vectors.
|
|
//!
|
|
//! The official vectors (opus_testvectors-rfc8251) are test data downloaded to
|
|
//! `target/fixtures/opus/vectors/` by `scripts/fetch-opus-vectors.sh` (never committed). Without
|
|
//! them the tests print `SKIPPED`, or fail with `CRAFT_MEDIA_REQUIRE_ORACLES=1` (as in CI).
|
|
//! For each vector we check (1) the final range-coder state after every packet against the value
|
|
//! stored in the `.bit` file (bit-exact parsing), and (2) the decoded PCM against the reference
|
|
//! `.dec` output using our implementation of the opus_compare-style quality metric
|
|
//! (see `common::opus_quality`), for stereo (`.dec`) and mono (`m.dec`) output at 48 kHz.
|
|
|
|
use super::common;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use craft_audio::opus::StreamDecoder;
|
|
|
|
/// The RFC 8251 vectors, or `None` after printing `SKIPPED` (a failure with
|
|
/// `CRAFT_MEDIA_REQUIRE_ORACLES=1`). `scripts/fetch-opus-vectors.sh` fetches them.
|
|
fn vectors_dir() -> Option<PathBuf> {
|
|
let d = crate::common::fixture_subdir("opus").join("vectors");
|
|
if d.join("testvector01.bit").exists() {
|
|
return Some(d);
|
|
}
|
|
crate::common::skip(&format!("the Opus RFC 8251 vectors are not in {} (scripts/fetch-opus-vectors.sh fetches them)", d.display()))
|
|
}
|
|
|
|
fn read_bit(path: &PathBuf) -> Vec<(Vec<u8>, u32)> {
|
|
let data = std::fs::read(path).unwrap();
|
|
let mut out = Vec::new();
|
|
let mut p = 0;
|
|
while p + 8 <= data.len() {
|
|
let len = u32::from_be_bytes(data[p..p + 4].try_into().unwrap()) as usize;
|
|
let rng = u32::from_be_bytes(data[p + 4..p + 8].try_into().unwrap());
|
|
p += 8;
|
|
if p + len > data.len() {
|
|
break;
|
|
}
|
|
out.push((data[p..p + len].to_vec(), rng));
|
|
p += len;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn read_pcm(path: &PathBuf) -> Vec<f32> {
|
|
std::fs::read(path).unwrap().as_chunks::<2>().0.iter().map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0).collect()
|
|
}
|
|
|
|
/// Decodes a vector; returns (interleaved output, index of first range mismatch).
|
|
fn decode_vector(packets: &[(Vec<u8>, u32)], rate: u32, channels: usize, no_inv: bool) -> (Vec<f32>, Option<usize>) {
|
|
let mut dec = StreamDecoder::new(rate, channels).unwrap();
|
|
if no_inv {
|
|
dec.set_phase_inversion_disabled(true);
|
|
}
|
|
let mut out = Vec::new();
|
|
let mut mismatch = None;
|
|
for (i, (pkt, rng)) in packets.iter().enumerate() {
|
|
let r = if pkt.is_empty() { dec.decode_interleaved(None, &mut out) } else { dec.decode_interleaved(Some(pkt), &mut out) };
|
|
if r.is_err() {
|
|
mismatch.get_or_insert(i);
|
|
continue;
|
|
}
|
|
if !pkt.is_empty() && dec.final_range() != *rng {
|
|
mismatch.get_or_insert(i);
|
|
}
|
|
}
|
|
(out, mismatch)
|
|
}
|
|
|
|
#[test]
|
|
fn rfc8251_test_vectors() {
|
|
let Some(dir) = vectors_dir() else { return };
|
|
let mut failures = Vec::new();
|
|
for v in 1..=12 {
|
|
let name = format!("testvector{v:02}");
|
|
let packets = read_bit(&dir.join(format!("{name}.bit")));
|
|
for (channels, suffix) in [(2usize, ""), (2usize, "m"), (1usize, "m")] {
|
|
let (out, mismatch) = decode_vector(&packets, 48000, channels, suffix == "m");
|
|
let mut reference = read_pcm(&dir.join(format!("{name}{suffix}.dec")));
|
|
if channels == 1 {
|
|
// Mono output is compared against the downmix of the phase-inversion-free reference.
|
|
reference = reference.as_chunks::<2>().0.iter().map(|c| 0.5 * (c[0] + c[1])).collect();
|
|
}
|
|
let q = common::opus_quality(&reference, &out, channels, 48000);
|
|
let snr = common::snr_db(&reference, &out);
|
|
let pass = mismatch.is_none() && q >= 0.0;
|
|
eprintln!(
|
|
"{name} {}: range {} quality {q:6.1} snr {snr:6.1} dB len {} / ref {} -> {}",
|
|
match (channels, suffix) {
|
|
(2, "") => "stereo ",
|
|
(2, _) => "stereo-m",
|
|
_ => "mono ",
|
|
},
|
|
mismatch.map_or("ok".to_string(), |i| format!("MISMATCH@{i}")),
|
|
out.len(),
|
|
reference.len(),
|
|
if pass { "PASS" } else { "FAIL" }
|
|
);
|
|
if !pass {
|
|
failures.push(format!("{name}{suffix} ({channels} ch)"));
|
|
}
|
|
}
|
|
}
|
|
assert!(failures.is_empty(), "failing vectors: {failures:?}");
|
|
}
|
|
|
|
/// Sanity check of the quality metric calibration: additive white noise at 48 dB SNR sits at the
|
|
/// pass threshold (quality ~0), at 70 dB it is clearly above it.
|
|
#[test]
|
|
fn quality_metric_calibration() {
|
|
let Some(dir) = vectors_dir() else {
|
|
return;
|
|
};
|
|
let reference = read_pcm(&dir.join("testvector01.dec"));
|
|
let reference = &reference[..reference.len().min(48000 * 2 * 20)];
|
|
let p: f64 = reference.iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / reference.len() as f64;
|
|
for (snr, lo, hi) in [(48.0f64, -15.0, 15.0), (70.0, 60.0, 101.0)] {
|
|
let amp = (p / 10f64.powf(snr / 10.0) * 3.0).sqrt();
|
|
let mut seed = 1u32;
|
|
let noisy: Vec<f32> = reference
|
|
.iter()
|
|
.map(|v| {
|
|
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
|
v + (((seed >> 8) as f64 / (1u64 << 24) as f64 * 2.0 - 1.0) * amp) as f32
|
|
})
|
|
.collect();
|
|
let q = common::opus_quality(reference, &noisy, 2, 48000);
|
|
eprintln!("white noise at {snr} dB SNR: quality {q:.1}");
|
|
assert!(q > lo && q < hi, "{snr} dB -> {q}");
|
|
}
|
|
}
|
|
|
|
/// Diagnostic (`VEC=n cargo test ... -- --ignored`): per-second SNR/quality with packet modes.
|
|
#[test]
|
|
#[ignore]
|
|
fn vector_diag() {
|
|
let Some(dir) = vectors_dir() else {
|
|
return;
|
|
};
|
|
let v: usize = std::env::var("VEC").ok().and_then(|s| s.parse().ok()).unwrap_or(2);
|
|
let name = format!("testvector{v:02}");
|
|
let packets = read_bit(&dir.join(format!("{name}.bit")));
|
|
let mut dec = StreamDecoder::new(48000, 2).unwrap();
|
|
let mut out = Vec::new();
|
|
let mut lines = Vec::new();
|
|
for (pkt, _) in &packets {
|
|
let start = out.len() / 2;
|
|
if pkt.is_empty() {
|
|
dec.decode_interleaved(None, &mut out).unwrap();
|
|
lines.push((start, "LOST".to_string()));
|
|
} else {
|
|
let p = craft_audio::opus::Packet::parse(pkt).unwrap();
|
|
dec.decode_interleaved(Some(pkt), &mut out).unwrap();
|
|
lines.push((start, format!("{:?} {:?} st{} n{}", p.toc.mode, p.toc.bandwidth, p.toc.stereo as u8, p.frames.len())));
|
|
}
|
|
}
|
|
let reference = read_pcm(&dir.join(format!("{name}.dec")));
|
|
let sec = 48000 * 2;
|
|
for s in 0..reference.len() / sec {
|
|
let r = &reference[s * sec..(s + 1) * sec];
|
|
let o = &out[s * sec..(s + 1) * sec];
|
|
let mut kinds: Vec<&String> = lines.iter().filter(|(st, _)| *st >= s * 48000 && *st < (s + 1) * 48000).map(|(_, c)| c).collect();
|
|
kinds.dedup();
|
|
eprintln!("{s:3}s snr {:6.1} q {:6.1} {:?}", common::snr_db(r, o), common::opus_quality(r, o, 2, 48000), kinds);
|
|
}
|
|
}
|