Files

188 lines
7.5 KiB
Rust

//! Ask the REAL decoder what it can open.
//!
//! The app carries several hand-maintained lists of "audio extensions", and a
//! list is a claim, not a capability — the only thing that settles whether a
//! file plays is building the same decoder the player builds.
//!
//! cargo run -p player --example probe_formats -- <file-or-folder> [more...]
//!
//! A folder is walked and one file per extension is probed, which is what makes
//! this cheap to point at a whole library.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
eprintln!("usage: probe_formats <file-or-folder>...");
std::process::exit(2);
}
// One representative per extension: the question is "can this FORMAT be
// decoded", and a thousand MP3s answer it no better than one.
let mut sample: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for a in &args {
collect(Path::new(a), &mut sample, &mut counts, 0);
}
println!("{:<8} {:>7} {:<9} {}", "EXT", "FILES", "VERDICT", "DETAIL");
for (ext, path) in &sample {
let (verdict, detail) = probe(path);
println!(
"{:<8} {:>7} {:<9} {}",
ext,
counts.get(ext).copied().unwrap_or(0),
verdict,
detail
);
}
}
fn collect(
p: &Path,
sample: &mut BTreeMap<String, PathBuf>,
counts: &mut BTreeMap<String, usize>,
depth: u8,
) {
if depth > 8 {
return;
}
if p.is_dir() {
let Ok(entries) = std::fs::read_dir(p) else { return };
for e in entries.flatten() {
collect(&e.path(), sample, counts, depth + 1);
}
return;
}
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { return };
let ext = ext.to_ascii_lowercase();
// Anything that is plausibly audio, INCLUDING formats no list mentions —
// the point is to discover the gap, not to confirm the lists.
const PLAUSIBLE: &[&str] = &[
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "aac", "alac", "ogg", "oga", "opus",
"wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm", "wma", "ape", "wv", "mpc",
"tta", "shn", "dsf", "dff", "ra", "au", "amr", "3gp", "spx",
];
if !PLAUSIBLE.contains(&ext.as_str()) {
return;
}
*counts.entry(ext.clone()).or_insert(0) += 1;
sample.entry(ext).or_insert_with(|| p.to_path_buf());
}
/// Build exactly what `player` builds for a local file.
fn probe(path: &Path) -> (&'static str, String) {
use rodio::Decoder;
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => return ("ERROR", format!("open failed: {e}")),
};
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let reader = std::io::BufReader::new(file);
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
if len > 0 {
builder = builder.with_byte_len(len);
}
// What did symphonia actually RECOGNISE? A verdict of "plays" is worth
// little if the container was mistaken for something else, so name the
// format and codec rather than inferring capability from the extension.
let detected = detect(path).unwrap_or_else(|| "?".into());
match builder.build() {
Ok(d) => {
// Opening is not playing. A container can parse while its codec is
// missing, and a truncated decode looks like success if you only
// pull a few thousand samples — so decode the WHOLE thing and check
// the duration against the file. A 30 MB lossless track that yields
// two seconds of audio has not been decoded, it has been guessed at.
use rodio::Source as _;
let rate = d.sample_rate();
let ch = d.channels();
let mut src = d;
// Amplitude tells apart "decoded" from "mis-identified". Compressed
// bytes read as raw PCM are full-scale noise: near-100% of samples
// land loud, where real music spends most of its time quiet.
let (mut n, mut sumsq, mut hot, mut bad) = (0u64, 0f64, 0u64, 0u64);
for s in src.by_ref() {
let v = s as f64;
// A non-finite sample is proof on its own: no decoder emits
// NaN from real audio. It comes from compressed bytes being
// reinterpreted as floats, and it poisons every mixer and
// normaliser stage downstream of here.
if !v.is_finite() {
bad += 1;
n += 1;
continue;
}
sumsq += v * v;
if v.abs() > 0.5 {
hot += 1;
}
n += 1;
}
let rms = if n > 0 { (sumsq / n as f64).sqrt() } else { 0.0 };
let hot_pct = if n > 0 { hot as f64 * 100.0 / n as f64 } else { 0.0 };
let frames = n as f64 / (ch.max(1) as f64);
let secs = frames / rate.max(1) as f64;
let mb = len as f64 / 1_048_576.0;
if secs < 1.0 {
return ("NO AUDIO", format!("{rate} Hz, {ch}ch — {secs:.2}s from {mb:.1} MB"));
}
// Even the densest lossless runs well under 10 MB per second.
if mb / secs > 10.0 {
return (
"PARTIAL",
format!("{rate} Hz, {ch}ch — only {secs:.1}s from {mb:.1} MB"),
);
}
// Half the samples above half scale is not music. This is the
// WavPack case: a RIFF header inside the wrapper makes symphonia
// read the compressed body as PCM, so it "succeeds" into noise —
// which is worse than refusing, because nothing reports an error.
if bad > 0 {
return (
"GARBAGE",
format!(
"{detected} mis-identified — {} of {n} samples are NaN/Inf",
bad
),
);
}
if hot_pct > 40.0 && rms > 0.35 {
return (
"NOISE",
format!("{detected} mis-identified — rms {rms:.2}, {hot_pct:.0}% of samples above half scale"),
);
}
(
"PLAYS",
format!("{detected} {rate} Hz, {ch}ch, {secs:.0}s, rms {rms:.2}"),
)
}
Err(e) => ("CANNOT", format!("{detected} {e}")),
}
}
/// The codec symphonia identifies, straight from the probe — the ground truth
/// behind a "plays" verdict.
fn detect(path: &Path) -> Option<String> {
use symphonia::core::io::MediaSourceStream;
use symphonia::core::probe::Hint;
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(&hint, mss, &Default::default(), &Default::default())
.ok()?;
let track = probed.format.default_track()?;
let codec = track.codec_params.codec;
let name = symphonia::default::get_codecs()
.get_codec(codec)
.map(|d| d.short_name.to_string())
.unwrap_or_else(|| format!("codec:{codec:?}"));
Some(name)
}