use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use rusty_chromaprint::{Configuration, Fingerprinter, Segment, match_fingerprints};
use crate::proc;
pub const DEFAULT_WINDOW_SECS: u32 = 120;
pub const SCORE_MAX: f64 = 8.0;
pub const COVERAGE_MIN: f32 = 0.80;
pub const SHIFT_ITEMS_MAX: i64 = 0;
pub const SPEED_RATIO_TOL: f64 = 0.005;
const CHUNK_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone)]
pub struct Fingerprint {
pub items: Vec<u32>,
pub scanned_secs: f32,
}
impl Fingerprint {
pub fn is_usable(&self) -> bool {
self.items.len() >= 32
}
}
pub fn config() -> Configuration {
Configuration::preset_test2()
}
pub fn fingerprint_file(path: &Path, window_secs: u32) -> Result<Fingerprint> {
if !path.exists() {
bail!("no such audio file: {}", path.display());
}
let cfg = config();
let rate = cfg.sample_rate();
let mut cmd = proc::capture("ffmpeg");
cmd.args([
"-nostdin", "-v", "error", "-i",
])
.arg(path)
.args(["-map", "0:a:0"])
.args(["-t", &(window_secs + 1).to_string()])
.args(["-f", "s16le", "-acodec", "pcm_s16le", "-ac", "1"])
.args(["-ar", &rate.to_string(), "-"])
.stdin(Stdio::null());
let mut child = cmd.spawn().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
anyhow!("ffmpeg not found — install it, or fingerprinting cannot work")
}
_ => anyhow!("could not run ffmpeg: {e}"),
})?;
let mut fp = Fingerprinter::new(&cfg);
fp.start(rate, 1)
.map_err(|e| anyhow!("chromaprint rejected {rate}Hz mono: {e:?}"))?;
let want_samples = (window_secs as u64) * (rate as u64);
let mut consumed: u64 = 0;
let mut buf = vec![0u8; CHUNK_BYTES];
let mut samples: Vec<i16> = Vec::with_capacity(CHUNK_BYTES / 2);
let mut carry: Option<u8> = None;
let mut hit_target = false;
{
let mut out = child
.stdout
.take()
.ok_or_else(|| anyhow!("ffmpeg produced no stdout pipe"))?;
loop {
let n = out.read(&mut buf).context("reading ffmpeg output")?;
if n == 0 {
break;
}
samples.clear();
let mut bytes = &buf[..n];
if let Some(hi) = carry.take()
&& let Some((first, rest)) = bytes.split_first()
{
samples.push(i16::from_le_bytes([hi, *first]));
bytes = rest;
}
let mut chunks = bytes.chunks_exact(2);
for c in &mut chunks {
samples.push(i16::from_le_bytes([c[0], c[1]]));
}
if let [last] = chunks.remainder() {
carry = Some(*last);
}
consumed += samples.len() as u64;
fp.consume(&samples);
if consumed >= want_samples {
hit_target = true;
break;
}
}
}
if hit_target {
let _ = child.kill();
let _ = child.wait();
} else {
let status = child.wait()?;
if !status.success() {
let mut err = Vec::new();
if let Some(mut s) = child.stderr.take() {
let _ = s.read_to_end(&mut err);
}
bail!(
"ffmpeg could not decode {}: {}",
path.display(),
proc::stderr_tail(&err)
);
}
}
fp.finish();
let items = fp.fingerprint().to_vec();
if items.is_empty() {
bail!("no audio decoded from {}", path.display());
}
Ok(Fingerprint {
items,
scanned_secs: consumed as f32 / rate as f32,
})
}
pub fn probe_duration_secs(path: &Path) -> Result<f64> {
let mut cmd = proc::capture("ffprobe");
cmd.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(path);
let out = proc::run_with_deadline(cmd, Instant::now() + Duration::from_secs(30))?;
if !out.status.success() {
bail!(
"ffprobe failed on {}: {}",
path.display(),
proc::stderr_tail(&out.stderr)
);
}
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<f64>()
.map_err(|_| anyhow!("ffprobe gave no duration for {}", path.display()))
}
#[derive(Debug, Clone, PartialEq)]
pub enum RejectReason {
NoCommonSegment,
Score { best: f64, max: f64 },
Coverage { got: f32, min: f32 },
TimeShift { shift_ms: i64, tol_items: i64 },
Speed {
ratio: f64,
tol: f64,
from: &'static str,
},
TooShort { items: usize },
DurationMismatch { a: i64, b: i64, tol: i64 },
}
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
Accept {
score: f64,
coverage: f32,
shift_ms: i64,
},
Reject {
reason: RejectReason,
score: f64,
coverage: f32,
shift_ms: i64,
},
}
impl Verdict {
pub fn is_accept(&self) -> bool {
matches!(self, Self::Accept { .. })
}
pub fn shift_ms(&self) -> i64 {
match self {
Self::Accept { shift_ms, .. } | Self::Reject { shift_ms, .. } => *shift_ms,
}
}
pub fn summary(&self) -> String {
match self {
Self::Accept {
score,
coverage,
shift_ms,
} => format!(
"same recording, aligned (score {score:.2}, coverage {coverage:.2}, \
shift {shift_ms}ms ±62ms)"
),
Self::Reject {
reason,
score,
coverage,
shift_ms,
} => match reason {
RejectReason::NoCommonSegment => {
"no audio in common — these are different recordings".into()
}
RejectReason::Score { best, max } => format!(
"different recording (score {best:.2}, needs ≤ {max:.2}; coverage {coverage:.2})"
),
RejectReason::Coverage { got, min } => format!(
"only {got:.2} of the track matches (needs ≥ {min:.2}) — \
a different edit, or one is a longer mix"
),
RejectReason::TimeShift { shift_ms, .. } => format!(
"same recording (score {score:.2}, coverage {coverage:.2}) but time-shifted \
by {shift_ms}ms — copied cues and the ANLZ beat grid would land \
{:.2}s off, and this tool cannot shift ANLZ contents",
*shift_ms as f64 / 1000.0
),
RejectReason::Speed { ratio, tol, from } => format!(
"{from} ratio {ratio:.4} exceeds {tol:.4} — likely a pitched or sped-up \
version, whose beat grid would be wrong anyway (shift {shift_ms}ms)"
),
RejectReason::TooShort { items } => {
format!("only {items} fingerprint items — too little audio to judge")
}
RejectReason::DurationMismatch { a, b, tol } => format!(
"lengths differ by {}s ({a}s vs {b}s, tolerance {tol}s) — \
not the same cut, so no audio was compared",
(a - b).abs()
),
},
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SpeedEvidence {
pub durations: Option<(f64, f64)>,
pub bpms: Option<(f64, f64)>,
}
impl SpeedEvidence {
fn violation(&self, tol: f64) -> Option<(f64, &'static str)> {
for (pair, name) in [(self.durations, "duration"), (self.bpms, "bpm")] {
if let Some((a, b)) = pair
&& a > 0.0
&& b > 0.0
{
let ratio = a / b;
if (ratio - 1.0).abs() > tol {
return Some((ratio, name));
}
}
}
None
}
}
#[derive(Debug, Clone, Copy)]
pub struct Thresholds {
pub score_max: f64,
pub coverage_min: f32,
pub shift_items_max: i64,
pub speed_ratio_tol: f64,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
score_max: SCORE_MAX,
coverage_min: COVERAGE_MIN,
shift_items_max: SHIFT_ITEMS_MAX,
speed_ratio_tol: SPEED_RATIO_TOL,
}
}
}
pub fn compare(
a: &Fingerprint,
b: &Fingerprint,
speed: SpeedEvidence,
t: &Thresholds,
) -> Result<Verdict> {
if !a.is_usable() || !b.is_usable() {
return Ok(Verdict::Reject {
reason: RejectReason::TooShort {
items: a.items.len().min(b.items.len()),
},
score: 0.0,
coverage: 0.0,
shift_ms: 0,
});
}
let cfg = config();
let segments = match_fingerprints(&a.items, &b.items, &cfg)
.map_err(|e| anyhow!("fingerprint comparison failed: {e}"))?;
Ok(decide(&segments, a, b, speed, t, &cfg))
}
fn decide(
segments: &[Segment],
a: &Fingerprint,
b: &Fingerprint,
speed: SpeedEvidence,
t: &Thresholds,
cfg: &Configuration,
) -> Verdict {
let Some(best) = segments.iter().max_by_key(|s| s.items_count) else {
return Verdict::Reject {
reason: RejectReason::NoCommonSegment,
score: 0.0,
coverage: 0.0,
shift_ms: 0,
};
};
let item_secs = cfg.item_duration_in_seconds();
let overlap = a.scanned_secs.min(b.scanned_secs).max(f32::EPSILON);
let coverage = (best.items_count as f32 * item_secs) / overlap;
let shift_items = best.offset1 as i64 - best.offset2 as i64;
let shift_ms = (shift_items as f64 * item_secs as f64 * 1000.0).round() as i64;
let score = best.score;
let reject = |reason| Verdict::Reject {
reason,
score,
coverage,
shift_ms,
};
if let Some((ratio, from)) = speed.violation(t.speed_ratio_tol) {
return reject(RejectReason::Speed {
ratio,
tol: t.speed_ratio_tol,
from,
});
}
if score > t.score_max {
return reject(RejectReason::Score {
best: score,
max: t.score_max,
});
}
if coverage < t.coverage_min {
return reject(RejectReason::Coverage {
got: coverage,
min: t.coverage_min,
});
}
if shift_items.abs() > t.shift_items_max {
return reject(RejectReason::TimeShift {
shift_ms,
tol_items: t.shift_items_max,
});
}
Verdict::Accept {
score,
coverage,
shift_ms,
}
}
pub fn debug_report(a: &Fingerprint, b: &Fingerprint) -> Result<String> {
let cfg = config();
let item_secs = cfg.item_duration_in_seconds();
let mut s = String::new();
s.push_str(&format!(
"config preset_test2 item = {:.4}s sample_rate {} mono\n",
item_secs,
cfg.sample_rate()
));
s.push_str(&format!(
"A {} items, scanned {:.1}s\nB {} items, scanned {:.1}s\n\n",
a.items.len(),
a.scanned_secs,
b.items.len(),
b.scanned_secs
));
if !a.is_usable() || !b.is_usable() {
s.push_str("too little audio to compare\n");
return Ok(s);
}
let segments = match_fingerprints(&a.items, &b.items, &cfg)
.map_err(|e| anyhow!("fingerprint comparison failed: {e}"))?;
s.push_str("seg offset1 offset2 items duration_s score shift_items shift_ms\n");
for (i, sg) in segments.iter().enumerate() {
let shift_items = sg.offset1 as i64 - sg.offset2 as i64;
s.push_str(&format!(
"{i:>3} {:>7} {:>7} {:>5} {:>10.2} {:>6.2} {:>11} {:>8}\n",
sg.offset1,
sg.offset2,
sg.items_count,
sg.duration(&cfg),
sg.score,
shift_items,
(shift_items as f64 * item_secs as f64 * 1000.0).round() as i64
));
}
if segments.is_empty() {
s.push_str(" (no common segments)\n");
}
Ok(s)
}
pub struct ScratchDir {
path: PathBuf,
armed: bool,
}
impl ScratchDir {
pub fn new() -> Result<Self> {
let path = crate::paths::scratch_root()?.join(format!(
"fp-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&path)
.with_context(|| format!("creating scratch dir {}", path.display()))?;
Ok(Self { path, armed: true })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn keep(&mut self) {
self.armed = false;
}
pub fn sweep_stale(max_age: Duration) -> Result<usize> {
let root = crate::paths::scratch_root()?;
if !root.exists() {
return Ok(0);
}
let mut removed = 0;
for entry in std::fs::read_dir(&root)? {
let entry = entry?;
if !entry.file_name().to_string_lossy().starts_with("fp-") {
continue;
}
let stale = entry
.metadata()
.and_then(|m| m.modified())
.map(|m| m.elapsed().map(|e| e > max_age).unwrap_or(false))
.unwrap_or(false);
if stale && std::fs::remove_dir_all(entry.path()).is_ok() {
removed += 1;
}
}
Ok(removed)
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_dir_all(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(items: usize, scanned: f32) -> Fingerprint {
Fingerprint {
items: vec![0; items],
scanned_secs: scanned,
}
}
fn seg(offset1: usize, offset2: usize, items_count: usize, score: f64) -> Segment {
Segment {
offset1,
offset2,
items_count,
score,
}
}
fn item_secs() -> f32 {
config().item_duration_in_seconds()
}
fn items_for(secs: f32) -> usize {
(secs / item_secs()).round() as usize
}
fn judge(segments: &[Segment], scanned: f32, speed: SpeedEvidence) -> Verdict {
let a = fp(items_for(scanned), scanned);
let b = fp(items_for(scanned), scanned);
decide(segments, &a, &b, speed, &Thresholds::default(), &config())
}
#[test]
fn the_documented_item_duration_is_what_the_library_actually_reports() {
let d = item_secs();
assert!(
(d - 0.1239).abs() < 0.0005,
"item duration changed: {d} — the shift tolerance reasoning needs revisiting"
);
assert_eq!(config().sample_rate(), 11025);
}
#[test]
fn an_aligned_full_length_match_is_accepted() {
let v = judge(
&[seg(0, 0, items_for(118.0), 2.41)],
120.0,
SpeedEvidence::default(),
);
assert!(v.is_accept(), "got {v:?}");
assert_eq!(v.shift_ms(), 0);
assert!(v.summary().contains("aligned"), "{}", v.summary());
}
#[test]
fn the_accept_message_admits_its_own_resolution_limit() {
let v = judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence::default(),
);
assert!(v.summary().contains("±62ms"), "{}", v.summary());
}
#[test]
fn a_time_shifted_match_is_rejected_even_though_the_recording_matches() {
let shift = 26; let v = judge(
&[seg(shift, 0, items_for(118.0), 1.20)],
120.0,
SpeedEvidence::default(),
);
match &v {
Verdict::Reject {
reason: RejectReason::TimeShift { shift_ms, .. },
score,
..
} => {
assert!(
*shift_ms > 3000 && *shift_ms < 3400,
"shift was {shift_ms}ms"
);
assert!(*score < 2.0, "the recording itself matched fine");
}
other => panic!("a shifted pair must be rejected, got {other:?}"),
}
assert!(v.summary().contains("time-shifted"), "{}", v.summary());
assert!(v.summary().contains("ANLZ"), "{}", v.summary());
}
#[test]
fn shift_direction_is_preserved_in_the_reported_value() {
let neg = judge(
&[seg(0, 26, items_for(118.0), 1.0)],
120.0,
SpeedEvidence::default(),
);
let pos = judge(
&[seg(26, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence::default(),
);
assert!(neg.shift_ms() < 0, "got {}", neg.shift_ms());
assert!(pos.shift_ms() > 0, "got {}", pos.shift_ms());
assert_eq!(neg.shift_ms(), -pos.shift_ms());
}
#[test]
fn a_one_item_shift_is_rejected_because_one_item_is_already_124ms() {
let v = judge(
&[seg(1, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence::default(),
);
assert!(!v.is_accept(), "a single item is ~124ms of drift");
}
#[test]
fn the_sub_item_blind_spot_is_documented_by_this_test_not_hidden() {
let v = judge(
&[seg(0, 0, items_for(118.0), 1.15)],
120.0,
SpeedEvidence::default(),
);
assert!(v.is_accept());
assert!(
v.summary().contains("±62ms"),
"an accept must state its resolution: {}",
v.summary()
);
}
const _: () = assert!(6.44 < SCORE_MAX);
#[test]
fn a_speed_change_is_caught_even_at_a_score_that_would_pass() {
let v = judge(
&[seg(0, 0, items_for(118.0), 6.44)],
120.0,
SpeedEvidence {
durations: Some((150.0, 147.06)),
bpms: None,
},
);
assert!(matches!(
v,
Verdict::Reject {
reason: RejectReason::Speed { .. },
..
}
));
}
#[test]
fn a_different_recording_is_rejected_on_score() {
let v = judge(
&[seg(0, 0, items_for(118.0), 19.0)],
120.0,
SpeedEvidence::default(),
);
assert!(matches!(
v,
Verdict::Reject {
reason: RejectReason::Score { .. },
..
}
));
}
#[test]
fn a_partial_match_is_rejected_on_coverage() {
let v = judge(
&[seg(0, 0, items_for(30.0), 1.0)],
120.0,
SpeedEvidence::default(),
);
match v {
Verdict::Reject {
reason: RejectReason::Coverage { got, .. },
..
} => assert!(got < 0.3, "coverage was {got}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn coverage_uses_only_the_best_segment_not_the_sum() {
let n = items_for(20.0);
let fragments: Vec<Segment> = (0..6).map(|i| seg(i * n, i * n, n, 1.0)).collect();
let v = judge(&fragments, 120.0, SpeedEvidence::default());
assert!(
!v.is_accept(),
"six 20s fragments must not sum to a full-length match: {v:?}"
);
}
#[test]
fn no_common_audio_is_rejected() {
let v = judge(&[], 120.0, SpeedEvidence::default());
assert!(matches!(
v,
Verdict::Reject {
reason: RejectReason::NoCommonSegment,
..
}
));
assert!(v.summary().contains("different recordings"));
}
#[test]
fn a_sped_up_reupload_is_rejected_on_duration_even_with_a_good_score() {
let v = judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence {
durations: Some((124.0, 121.5)), bpms: None,
},
);
match v {
Verdict::Reject {
reason: RejectReason::Speed { ratio, from, .. },
..
} => {
assert_eq!(from, "duration");
assert!(ratio > 1.01, "ratio was {ratio}");
}
other => panic!("a 2% speed change must be rejected, got {other:?}"),
}
}
#[test]
fn bpm_disagreement_alone_is_enough_to_reject() {
let v = judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence {
durations: None,
bpms: Some((128.0, 130.0)),
},
);
assert!(matches!(
v,
Verdict::Reject {
reason: RejectReason::Speed { from: "bpm", .. },
..
}
));
}
#[test]
fn tiny_speed_differences_within_tolerance_are_accepted() {
let v = judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence {
durations: Some((124.383, 124.352)),
bpms: Some((163.16, 163.16)),
},
);
assert!(v.is_accept(), "got {v:?}");
}
#[test]
fn absent_speed_evidence_does_not_reject() {
assert!(
judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence {
durations: None,
bpms: None
}
)
.is_accept()
);
}
#[test]
fn zero_or_missing_values_are_not_treated_as_a_ratio() {
assert!(
judge(
&[seg(0, 0, items_for(118.0), 1.0)],
120.0,
SpeedEvidence {
durations: Some((0.0, 124.0)),
bpms: Some((0.0, 0.0))
}
)
.is_accept()
);
}
#[test]
fn a_duration_prefilter_rejection_names_the_lengths_not_coverage() {
let v = Verdict::Reject {
reason: RejectReason::DurationMismatch {
a: 150,
b: 205,
tol: 2,
},
score: f64::NAN,
coverage: 0.0,
shift_ms: -55_000,
};
let s = v.summary();
assert!(s.contains("lengths differ by 55s"), "got: {s}");
assert!(s.contains("150s vs 205s"), "got: {s}");
assert!(s.contains("no audio was compared"), "got: {s}");
assert!(
!s.contains("longer mix"),
"must not reuse the coverage wording"
);
}
#[test]
fn too_little_audio_is_rejected_rather_than_guessed() {
let v = compare(
&fp(4, 0.5),
&fp(4, 0.5),
SpeedEvidence::default(),
&Thresholds::default(),
)
.unwrap();
assert!(matches!(
v,
Verdict::Reject {
reason: RejectReason::TooShort { .. },
..
}
));
}
#[test]
fn coverage_is_measured_against_the_shorter_scan() {
let short = fp(items_for(60.0), 60.0);
let long = fp(items_for(120.0), 120.0);
let v = decide(
&[seg(0, 0, items_for(59.0), 1.0)],
&short,
&long,
SpeedEvidence::default(),
&Thresholds::default(),
&config(),
);
assert!(
v.is_accept(),
"a fully-matched shorter file should pass: {v:?}"
);
}
#[test]
fn thresholds_are_overridable_without_changing_the_rule() {
let segments = [seg(0, 0, items_for(118.0), 12.0)];
let a = fp(items_for(120.0), 120.0);
let strict = decide(
&segments,
&a,
&a,
SpeedEvidence::default(),
&Thresholds::default(),
&config(),
);
assert!(!strict.is_accept());
let loose = decide(
&segments,
&a,
&a,
SpeedEvidence::default(),
&Thresholds {
score_max: 15.0,
..Default::default()
},
&config(),
);
assert!(loose.is_accept(), "raising score_max should admit it");
}
#[test]
fn debug_report_prints_every_segment_for_calibration() {
let a = fp(items_for(120.0), 120.0);
let r = debug_report(&a, &a).unwrap();
assert!(r.contains("preset_test2"));
assert!(r.contains("item = 0.12"), "{r}");
assert!(r.contains("shift_ms"), "{r}");
}
#[test]
fn debug_report_says_so_when_there_is_not_enough_audio() {
let r = debug_report(&fp(2, 0.2), &fp(2, 0.2)).unwrap();
assert!(r.contains("too little audio"), "{r}");
}
#[test]
fn a_missing_file_is_an_error_not_a_panic() {
let err = fingerprint_file(Path::new("/nonexistent/x.flac"), 10).unwrap_err();
assert!(err.to_string().contains("no such audio file"), "{err}");
}
#[test]
fn scratch_dirs_delete_themselves_and_can_be_kept() {
let path = {
let s = ScratchDir::new().unwrap();
let p = s.path().to_path_buf();
assert!(p.exists());
std::fs::write(p.join("x.bin"), b"data").unwrap();
p
};
assert!(!path.exists(), "dropping a scratch dir must remove it");
let kept = {
let mut s = ScratchDir::new().unwrap();
s.keep();
s.path().to_path_buf()
};
assert!(kept.exists(), "keep() should defuse cleanup");
std::fs::remove_dir_all(&kept).unwrap();
}
#[test]
fn sweeping_leaves_fresh_scratch_dirs_alone() {
let s = ScratchDir::new().unwrap();
let path = s.path().to_path_buf();
ScratchDir::sweep_stale(Duration::from_secs(86_400)).unwrap();
assert!(path.exists(), "a scratch dir in use must survive a sweep");
}
}