Files
SoulStream/src-tauri/src/setlist_scan.rs
T

1006 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The DJ-set LISTENER: window the set's audio, recognize each window
//! (shazam.rs), build a consensus setlist, and project it into the same
//! ImportDraft the other import doors produce. The research bundle's
//! phases 5–7 in their honest v1 shape: fixed sampling grid, temporal
//! consensus by track key + offset clustering, Unknown gaps preserved as
//! "ID - ID" rows with timestamps, nothing auto-invented.
//!
//! The boundary math is the live-proven offset trick: Shazam reports where
//! a window sits INSIDE the identified track (millisecond-faithful — see
//! shazam.rs's live test), so `window_position − in_track_offset` is the
//! track's start in the mix, and agreeing windows cluster tightly around
//! the true start even on a sparse grid.
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Emitter;
use crate::shazam;
/// Sampling grid: one 12 s window every 45 s. An hour-long set is ~80
/// windows ≈ ~3–4 minutes of paced analysis.
const STEP_SECS: usize = 45;
/// Pacing between recognition calls — the Bing lesson: unpaced scraping is
/// how providers break.
const PACE_MS: u64 = 1_800;
/// Two implied starts within this many seconds agree.
const CLUSTER_SECS: f64 = 6.0;
/// A gap this long with no recognized audio earns an explicit ID row.
const ID_GAP_SECS: usize = 2 * STEP_SECS;
/// Longest set the scanner will decode (memory bound: ~4 h of 16 kHz mono
/// f32 ≈ 920 MB).
const MAX_SET_SECS: f64 = 4.0 * 3600.0;
static CANCEL: AtomicBool = AtomicBool::new(false);
#[tauri::command]
pub fn playlist_scan_cancel() {
CANCEL.store(true, Ordering::Relaxed);
}
/// One window's verdict, kept for consensus — and persisted as the
/// listen artifact, so a re-scan of the same audio replays hearings
/// instead of re-paying recognition.
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct Observation {
pos_secs: f64,
rec: Option<shazam::Recognition>,
}
/// A consensus track occurrence in the set.
pub(crate) struct Occurrence {
pub(crate) start_secs: f64,
pub(crate) artist: String,
pub(crate) title: String,
/// The heard release's ISRC (from the same matched release that names
/// the occurrence) — the review's anchor-exact catalog key.
pub(crate) isrc: Option<String>,
pub(crate) votes: usize,
}
/// One row of the video's own posted tracklist (chapters / description /
/// comment), time-stamped — the human evidence the listener cross-checks
/// its hearings against.
pub(crate) struct DeclaredRow {
pub(crate) start_secs: f64,
pub(crate) artist: Option<String>,
pub(crate) title: String,
}
fn base_title(title: &str) -> &str {
title.split(['(', '[']).next().unwrap_or(title).trim()
}
/// The version qualifier of a title — what sits in (…)/[…] after the base,
/// normalized: "Skanka (Kayzo Remix)" → "kayzo remix"; a plain title → "".
fn variant_qualifier(title: &str) -> String {
let base_len = title.split(['(', '[']).next().unwrap_or(title).len();
title[base_len..]
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn norm_name(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
/// The DJ-culture unknown-track token — never an identity to match on.
fn is_id_token(title: &str) -> bool {
let n = norm_name(title);
n == "id" || n == "idid"
}
/// Project tracklist text (a video's posted list, or one pasted alongside a
/// local file) into declared rows. Only timestamped, titled lines can anchor
/// to the mix — the rest are dropped; parse failure is an empty list.
fn declared_rows(text: &str) -> Vec<DeclaredRow> {
crate::playlist_formats::parse_text(text.as_bytes())
.map(|parsed| {
parsed
.entries
.into_iter()
.filter_map(|e| {
Some(DeclaredRow {
start_secs: e.start_ms? as f64 / 1000.0,
artist: e.artist,
title: e.title?,
})
})
.collect()
})
.unwrap_or_default()
}
/// Let the video's posted tracklist name the VERSION where the hearing
/// matched a different release of the same track: the fingerprint can only
/// report the release it matched (a remix's unmodified stretches hit the
/// original it samples), while the DJ's own list is authoritative for what
/// was actually played. Only qualifiers move — a declared row must agree on
/// artist and base title, sit within two grid steps of the heard boundary,
/// and CARRY a version qualifier of its own; a plain declared title never
/// strips a heard one. A corrected row loses its heard ISRC — that anchor
/// belongs to the other release — and resolves by its declared name in the
/// review instead.
pub(crate) fn apply_declared_variants(
confirmed: &mut [Occurrence],
declared: &[DeclaredRow],
) -> Vec<String> {
let mut notes = Vec::new();
for o in confirmed.iter_mut() {
let heard_base = norm_name(base_title(&o.title));
if heard_base.is_empty() {
continue;
}
let candidate = declared
.iter()
.filter(|d| !is_id_token(&d.title))
.filter(|d| (d.start_secs - o.start_secs).abs() <= 2.0 * STEP_SECS as f64)
.filter(|d| {
let declared_base = norm_name(base_title(&d.title));
!declared_base.is_empty()
&& (declared_base == heard_base
|| declared_base.contains(&heard_base)
|| heard_base.contains(&declared_base))
})
.filter(|d| match d.artist.as_deref() {
None => true,
Some(a) => {
let declared_artist = norm_name(a);
let heard_artist = norm_name(&o.artist);
declared_artist == heard_artist
|| declared_artist.contains(&heard_artist)
|| heard_artist.contains(&declared_artist)
}
})
.min_by(|a, b| {
(a.start_secs - o.start_secs)
.abs()
.total_cmp(&(b.start_secs - o.start_secs).abs())
});
let Some(d) = candidate else { continue };
let declared_q = variant_qualifier(&d.title);
if declared_q.is_empty() || declared_q == variant_qualifier(&o.title) {
continue;
}
notes.push(format!("{} - {} (heard as \"{}\")", o.artist, d.title, o.title));
o.title = d.title.clone();
o.isrc = None;
}
notes
}
/// The identity two windows must share to be the SAME played track: artist
/// plus the title up to its first parenthetical. Live sets match DIFFERENT
/// RELEASES of one track window to window (studio, "Live At…", edits) —
/// exact-title grouping fragments them (the Cappadocia lesson: one track
/// became four "occurrences").
fn norm_key(rec: &shazam::Recognition) -> (String, String) {
let base = rec
.title
.split(['(', '['])
.next()
.unwrap_or(&rec.title)
.trim()
.to_lowercase();
(rec.artist.to_lowercase(), base)
}
/// Group observations into occurrences: same normalized identity AND
/// window-adjacent (within 2 grid steps of the cluster's last member).
/// Offset-implied starts refine the boundary when they agree; when a live
/// edit scatters them, the first window's position stands (late, never
/// early — honest). A track genuinely played twice is two clusters because
/// adjacency breaks between them.
pub(crate) fn consensus(observations: &[Observation]) -> Vec<Occurrence> {
struct Cluster {
key: (String, String),
first_pos: f64,
last_pos: f64,
implieds: Vec<f64>,
recs: Vec<shazam::Recognition>,
}
let mut clusters: Vec<Cluster> = Vec::new();
for obs in observations {
let Some(rec) = &obs.rec else { continue };
let key = norm_key(rec);
let implied = rec.offset_secs.map(|off| obs.pos_secs - off);
match clusters
.iter_mut()
.rev()
.find(|c| c.key == key && obs.pos_secs - c.last_pos <= 2.0 * STEP_SECS as f64 + 1.0)
{
Some(c) => {
c.last_pos = obs.pos_secs;
c.recs.push(rec.clone());
if let Some(i) = implied {
c.implieds.push(i);
}
}
None => clusters.push(Cluster {
key,
first_pos: obs.pos_secs,
last_pos: obs.pos_secs,
implieds: implied.into_iter().collect(),
recs: vec![rec.clone()],
}),
}
}
let mut out: Vec<Occurrence> = clusters
.into_iter()
.map(|mut c| {
// Start: the tightest agreeing pair of implied starts wins;
// scattered offsets (live edits) fall back to the first window.
let mut start = c.first_pos;
let mut implieds = c.implieds.clone();
implieds.sort_by(f64::total_cmp);
for pair in implieds.windows(2) {
if pair[1] - pair[0] <= 2.0 * CLUSTER_SECS {
start = (pair[0] + pair[1]) / 2.0;
break;
}
}
if c.implieds.len() == 1 && (c.implieds[0] - c.first_pos).abs() <= STEP_SECS as f64 {
start = c.implieds[0];
}
// The DISPLAYED identity is the cluster's most-heard exact
// variant, not blindly the first window's — the first window
// sits in the transition zone, the most error-prone hearing
// (a remix's intro matches the original it samples). Ties keep
// the earliest; artist, title and ISRC stay coherent, all from
// the SAME matched release.
let votes = c.recs.len();
let mut best = 0usize;
let mut best_votes = 0usize;
for i in 0..c.recs.len() {
let n = c
.recs
.iter()
.filter(|r| r.title.eq_ignore_ascii_case(&c.recs[i].title))
.count();
if n > best_votes {
best_votes = n;
best = i;
}
}
let id = c.recs.swap_remove(best);
Occurrence {
start_secs: start.max(0.0),
isrc: id.isrc,
artist: id.artist,
title: id.title,
votes,
}
})
.collect();
out.sort_by(|a, b| a.start_secs.total_cmp(&b.start_secs));
out
}
/// Project occurrences + unrecognized gaps into pasted-tracklist LINES —
/// the exact shapes playlist_formats::parse_text accepts, so the listener
/// rides the same parser/resolver/review/commit as every other door.
pub(crate) fn tracklist_lines(
occurrences: &[Occurrence],
observations: &[Observation],
set_len_secs: f64,
declared: &[DeclaredRow],
) -> (String, Vec<String>) {
let fmt = |s: f64| {
let t = s.max(0.0) as i64;
let (h, m, sec) = (t / 3600, (t % 3600) / 60, t % 60);
if h > 0 {
format!("{h}:{m:02}:{sec:02}")
} else {
format!("{m}:{sec:02}")
}
};
let mut lines: Vec<(f64, String)> = occurrences
.iter()
.map(|o| {
(
o.start_secs,
format!("{} {} - {}", fmt(o.start_secs), o.artist, o.title),
)
})
.collect();
// Unknown gaps: a RUN of unrecognized windows ≥ ID_GAP_SECS becomes one
// explicit ID row at the run's start — UNLESS the video's own tracklist
// declares rows inside the run, which then stand in for it (named human
// evidence beats an anonymous placeholder; the fingerprint simply
// doesn't know unreleased material). A gap nothing was declared for
// keeps the honest ID — inventing nothing there is still the rule.
let mut filled: Vec<String> = Vec::new();
let mut fill_gap =
|start: f64, end: f64, lines: &mut Vec<(f64, String)>, filled: &mut Vec<String>| {
let inside: Vec<&DeclaredRow> = declared
.iter()
.filter(|d| !is_id_token(&d.title))
.filter(|d| d.start_secs >= start - 1.0 && d.start_secs < end)
.collect();
if inside.is_empty() {
lines.push((start, format!("{} ID - ID", fmt(start))));
return;
}
for d in inside {
let line = match d.artist.as_deref() {
Some(a) => format!("{} {} - {}", fmt(d.start_secs), a, d.title),
None => format!("{} {}", fmt(d.start_secs), d.title),
};
filled.push(match d.artist.as_deref() {
Some(a) => format!("{a} - {}", d.title),
None => d.title.clone(),
});
lines.push((d.start_secs, line));
}
};
let mut gap_start: Option<f64> = None;
for obs in observations {
match (&obs.rec, gap_start) {
(Some(_), Some(start)) => {
if obs.pos_secs - start >= ID_GAP_SECS as f64 {
fill_gap(start, obs.pos_secs, &mut lines, &mut filled);
}
gap_start = None;
}
(None, None) => gap_start = Some(obs.pos_secs),
_ => {}
}
}
if let Some(start) = gap_start {
if set_len_secs - start >= ID_GAP_SECS as f64 {
fill_gap(start, set_len_secs, &mut lines, &mut filled);
}
}
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
let text = lines
.into_iter()
.map(|(_, l)| l)
.collect::<Vec<_>>()
.join("\n");
(text, filled)
}
/// Listen through a decoded set. Emits `playlist:scan-progress`
/// {stage:"listen", window, total, hit?} as it goes; honors the cancel
/// flag between windows; paces every request. Returns the raw
/// observations — consensus is the caller's, so remembered hearings
/// replay through the same math as fresh ones.
pub(crate) async fn scan_pcm16k(
app: &tauri::AppHandle,
mono16k: &[f32],
) -> Result<Vec<Observation>, String> {
CANCEL.store(false, Ordering::Relaxed);
let total_windows =
(mono16k.len() / (STEP_SECS * shazam::SAMPLE_RATE)).max(1);
let mut observations: Vec<Observation> = Vec::new();
let mut consecutive_errors = 0usize;
for w in 0..total_windows {
if CANCEL.load(Ordering::Relaxed) {
return Err("canceled".into());
}
let start = w * STEP_SECS * shazam::SAMPLE_RATE;
let end = (start + shazam::WINDOW_SECS * shazam::SAMPLE_RATE).min(mono16k.len());
if end <= start + shazam::SAMPLE_RATE {
break;
}
let pos_secs = (start / shazam::SAMPLE_RATE) as f64;
let rec = match shazam::recognize_window(&mono16k[start..end]).await {
Ok(r) => {
consecutive_errors = 0;
r
}
Err(e) if e.contains("rate-limited") => {
// One long breath, one retry; then give the window up.
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
consecutive_errors += 1;
if consecutive_errors > 4 {
return Err("the recognition service is rate-limiting hard — try again later".into());
}
shazam::recognize_window(&mono16k[start..end])
.await
.unwrap_or(None)
}
Err(_) => {
consecutive_errors += 1;
if consecutive_errors > 6 {
return Err(
"the recognition service stopped answering — partial scan abandoned".into(),
);
}
None
}
};
let _ = app.emit(
"playlist:scan-progress",
serde_json::json!({
"stage": "listen",
"window": w + 1,
"total": total_windows,
"hit": rec.as_ref().map(|r| format!("{} - {}", r.artist, r.title)),
}),
);
observations.push(Observation { pos_secs, rec });
tokio::time::sleep(std::time::Duration::from_millis(PACE_MS)).await;
}
Ok(observations)
}
/// The whole listener, YouTube edition: yt-dlp the audio (cache-first) →
/// decode → scan → draft through the standard pipeline.
#[tauri::command]
pub async fn playlist_scan_youtube(
app: tauri::AppHandle,
lib: tauri::State<'_, crate::pipeline::LibraryState>,
url: String,
) -> Result<crate::playlists::ImportDraft, String> {
let video_id = crate::youtube_setlist::video_id_from_url(&url)
.ok_or("That doesn't look like a YouTube video link")?;
CANCEL.store(false, Ordering::Relaxed);
// The FULL fetch first: a posted tracklist is free supplementary
// evidence for the listener — it names the VERSION played where the
// fingerprint can only name the release it matched, and it fills
// unrecognized gaps. Failure-soft: listener sets usually have no
// posted tracklist, and the light title fetch still names the draft
// (the raw-video-id lesson).
let (page_title, declared) = match crate::youtube_setlist::fetch(&url).await {
Ok(fetched) => (
Some((fetched.title, fetched.author)),
declared_rows(&fetched.tracklist),
),
Err(_) => (crate::youtube_setlist::fetch_title(&url).await, Vec::new()),
};
let audio = {
let app2 = app.clone();
let vid = video_id.clone();
tauri::async_runtime::spawn_blocking(move || {
crate::ytdlp::fetch_audio_blocking(&app2, &vid, &CANCEL)
})
.await
.map_err(|e| e.to_string())??
};
let mut draft =
scan_file_into_draft(app, lib, audio, page_title, declared, "the video's tracklist")
.await?;
// The video's own thumbnail is the playlist's default face.
draft.cover_url = Some(format!(
"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
));
Ok(draft)
}
/// What the local door's picker hands the modal: enough to show the file
/// while the user decides whether to paste a tracklist alongside it.
#[derive(serde::Serialize)]
pub struct PickedSetFile {
path: String,
name: String,
duration_secs: Option<u32>,
}
/// The dialog half of the local door, split from the scan so the modal can
/// collect an optional pasted tracklist between pick and listen.
#[tauri::command]
pub async fn playlist_pick_set_file(
app: tauri::AppHandle,
) -> Result<Option<PickedSetFile>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("Audio", &["mp3", "m4a", "aac", "flac", "wav", "ogg", "mp4"])
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(path) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let name = std::path::Path::new(&path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
// Header-only probe for the panel's length chip; a file lofty can't
// read still listens fine.
let duration_secs = {
let p = path.clone();
tauri::async_runtime::spawn_blocking(move || {
tagger::read_summary(std::path::Path::new(&p))
.ok()
.and_then(|s| s.duration_secs)
})
.await
.unwrap_or(None)
};
Ok(Some(PickedSetFile {
path,
name,
duration_secs,
}))
}
/// The whole listener, local-file edition: scan a picked set recording,
/// with an optionally pasted tracklist riding along as the same declared
/// evidence the YouTube door fetches for itself.
#[tauri::command]
pub async fn playlist_scan_file(
app: tauri::AppHandle,
lib: tauri::State<'_, crate::pipeline::LibraryState>,
path: String,
tracklist: Option<String>,
) -> Result<crate::playlists::ImportDraft, String> {
CANCEL.store(false, Ordering::Relaxed);
let declared = tracklist.as_deref().map(declared_rows).unwrap_or_default();
let name = std::path::Path::new(&path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned());
scan_file_into_draft(
app,
lib,
std::path::PathBuf::from(path),
name.map(|n| (n, String::new())),
declared,
"the pasted tracklist",
)
.await
}
// ---- the listen artifact (observation cache) --------------------------------
/// Bump when the recognizer's output changes shape or meaning (v2: ISRC
/// capture) — an old artifact must not answer for a newer listener.
const LISTEN_VERSION: u32 = 2;
#[derive(serde::Serialize, serde::Deserialize)]
struct ListenArtifact {
version: u32,
/// The sampling grid the observations were taken on; a different grid
/// is a different listen.
step_secs: usize,
set_len_secs: f64,
observations: Vec<Observation>,
}
/// The audio file's identity — the lyric_prep idiom: path + size + mtime,
/// so a re-downloaded (re-encoded) file re-listens.
fn listen_key(audio: &std::path::Path) -> String {
let meta = std::fs::metadata(audio).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!(
"{:016x}-v{}",
crate::lyric_prep::fnv(audio.to_string_lossy().to_lowercase().as_bytes())
^ size
^ (mtime << 20),
LISTEN_VERSION,
)
}
fn listen_artifact_path(app: &tauri::AppHandle, key: &str) -> Option<std::path::PathBuf> {
use tauri::Manager as _;
let dir = app.path().app_data_dir().ok()?.join("setlist-listen");
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join(format!("{key}.json")))
}
fn read_listen_artifact(app: &tauri::AppHandle, key: &str) -> Option<ListenArtifact> {
let bytes = std::fs::read(listen_artifact_path(app, key)?).ok()?;
let artifact: ListenArtifact = serde_json::from_slice(&bytes).ok()?;
(artifact.version == LISTEN_VERSION && artifact.step_secs == STEP_SECS).then_some(artifact)
}
/// Best-effort — a cache write failure must never fail the scan it stores.
fn write_listen_artifact(
app: &tauri::AppHandle,
key: &str,
observations: &[Observation],
set_len_secs: f64,
) {
let Some(path) = listen_artifact_path(app, key) else {
return;
};
let artifact = ListenArtifact {
version: LISTEN_VERSION,
step_secs: STEP_SECS,
set_len_secs,
observations: observations.to_vec(),
};
if let Ok(bytes) = serde_json::to_vec(&artifact) {
let _ = std::fs::write(&path, bytes);
}
}
async fn scan_file_into_draft(
app: tauri::AppHandle,
lib: tauri::State<'_, crate::pipeline::LibraryState>,
audio: std::path::PathBuf,
identity: Option<(String, String)>,
declared: Vec<DeclaredRow>,
// Where the declared rows came from, for the provenance warnings:
// "the video's tracklist" or "the pasted tracklist".
declared_src: &str,
) -> Result<crate::playlists::ImportDraft, String> {
// A set already listened to on this machine answers from its listen
// artifact: the audio was cached, but recognition still re-paid ~5
// paced minutes per re-scan. Hearings don't change; consensus
// re-derives at read time, so tuning it never invalidates one.
let key = listen_key(&audio);
let remembered = read_listen_artifact(&app, &key);
let from_cache = remembered.is_some();
let (observations, set_len) = match remembered {
Some(artifact) => (artifact.observations, artifact.set_len_secs),
None => {
let _ = app.emit(
"playlist:scan-progress",
serde_json::json!({ "stage": "decode" }),
);
let decode_path = audio.clone();
let mono16k = tauri::async_runtime::spawn_blocking(move || {
player::vocals::mono_pcm_16k_streaming(&decode_path, MAX_SET_SECS)
})
.await
.map_err(|e| e.to_string())??;
let set_len = mono16k.len() as f64 / shazam::SAMPLE_RATE as f64;
let observations = scan_pcm16k(&app, &mono16k).await?;
(observations, set_len)
}
};
let occurrences = consensus(&observations);
if occurrences.is_empty() {
return Err(
"Nothing in the set was recognized — it may be all unreleased material, or the \
service is unreachable. The tracklist doors still work."
.into(),
);
}
// Rows need corroboration: two agreeing windows (≥ ~90 s of presence).
// Single-window hits are transition-misfire territory — they stay
// EVIDENCE (a warning line), never rows (research §7: low-confidence
// candidates are kept as evidence only).
let (mut confirmed, singles): (Vec<_>, Vec<_>) =
occurrences.into_iter().partition(|o| o.votes >= 2);
if confirmed.is_empty() {
return Err(
"Nothing was corroborated across windows — the set may be mostly unreleased \
material. The tracklist doors still work."
.into(),
);
}
// Only a scan worth keeping is worth remembering — a deaf run (the
// service flaking window to window) must re-listen next time.
if !from_cache {
write_listen_artifact(&app, &key, &observations, set_len);
}
let variant_notes = apply_declared_variants(&mut confirmed, &declared);
let (text, gap_filled) = tracklist_lines(&confirmed, &observations, set_len, &declared);
let parsed = crate::playlist_formats::parse_text(text.as_bytes())?;
let (title, author) = identity.unwrap_or_else(|| {
(
audio
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Recognized set".into()),
String::new(),
)
});
let source = if author.is_empty() {
title.clone()
} else {
format!("{author} — {title}")
};
let mut draft = crate::playlists::resolve_draft(&lib, parsed, source, None).await?;
draft.format = "listened".into();
draft.name = Some(title);
// Each row gets its heard release's ISRC back — matched by the same
// second + artist its projected line carried — so the review can
// resolve the catalog by exact recording id before trying any name.
for e in draft.entries.iter_mut() {
let Some(ms) = e.start_ms else { continue };
if let Some(o) = confirmed
.iter()
.find(|o| o.start_secs.max(0.0) as i64 == ms / 1000 && o.artist == e.artist)
{
e.isrc = o.isrc.clone();
}
}
// An unidentified stretch still has its AUDIO — the set recording is on
// disk (the YT cache, or the picked file itself). ID rows keep the
// recording's path and a "segment" resolution: they commit as playable
// SLICES (this row's cue to the next row's), so nothing in the set is
// unplayable just because nobody knows its name yet.
let audio_path = audio.to_string_lossy().into_owned();
let mut sliced = 0usize;
for e in draft.entries.iter_mut() {
if e.resolution == "unresolved" && e.start_ms.is_some() {
e.resolution = "segment".into();
e.path = Some(audio_path.clone());
sliced += 1;
}
}
let matched: usize = observations.iter().filter(|o| o.rec.is_some()).count();
draft.warnings.insert(
0,
format!(
"Recognized by listening: {matched} of {} windows matched, {} tracks corroborated.{}",
observations.len(),
confirmed.len(),
if from_cache {
" Remembered from an earlier listen of this audio."
} else {
""
}
),
);
if !singles.is_empty() {
let mut names: Vec<String> = singles
.iter()
.map(|o| format!("{} - {}", o.artist, o.title))
.collect();
names.sort();
names.dedup();
draft.warnings.push(format!(
"Heard once each, left out of the list (transition flickers, usually): {}.",
names.join(" · ")
));
}
if !variant_notes.is_empty() {
draft.warnings.push(format!(
"Named by {declared_src} — the recording matched a different release: {}.",
variant_notes.join(" · ")
));
}
if !gap_filled.is_empty() {
draft.warnings.push(format!(
"Unrecognized stretches filled from {declared_src}: {}.",
gap_filled.join(" · ")
));
}
if sliced > 0 {
draft.warnings.push(format!(
"{sliced} unidentified stretch{} will play as slices of the set's own recording.",
if sliced == 1 { "" } else { "es" }
));
}
Ok(draft)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declared_rows_keep_only_timestamped_titled_lines() {
let rows =
declared_rows("00:00 Bicep - Glue\nFour Tet - Baby\n05:30 Overmono - So U Kno");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].title, "Glue");
assert_eq!(rows[0].artist.as_deref(), Some("Bicep"));
assert_eq!(rows[1].start_secs, 330.0);
}
fn obs(pos: f64, hit: Option<(&str, &str, f64)>) -> Observation {
Observation {
pos_secs: pos,
rec: hit.map(|(artist, title, offset)| shazam::Recognition {
artist: artist.into(),
title: title.into(),
key: format!("{artist}-{title}"),
offset_secs: Some(offset),
album: None,
cover_url: None,
isrc: None,
}),
}
}
/// Three windows inside one track must collapse to ONE occurrence whose
/// start is the offset-implied track start, not the first window hit.
#[test]
fn consensus_clusters_by_implied_start() {
let observations = vec![
obs(45.0, Some(("Bicep", "Glue", 40.0))), // implies 5.0
obs(90.0, Some(("Bicep", "Glue", 85.5))), // implies 4.5
obs(135.0, Some(("Bicep", "Glue", 130.2))), // implies 4.8
obs(180.0, None),
obs(225.0, Some(("Overmono", "So U Kno", 11.0))), // implies 214.0
];
let out = consensus(&observations);
assert_eq!(out.len(), 2);
assert_eq!(out[0].title, "Glue");
assert_eq!(out[0].votes, 3);
assert!((out[0].start_secs - 4.65).abs() < 0.2, "{}", out[0].start_secs);
assert_eq!(out[1].votes, 1);
assert!((out[1].start_secs - 214.0).abs() < 0.1);
}
/// The live-set lesson: one played track matches DIFFERENT releases per
/// window (title variants, junk offsets). Normalized identity +
/// adjacency must fuse them into ONE occurrence, anchored at the first
/// window when the offsets scatter.
#[test]
fn consensus_fuses_release_variants_of_one_played_track() {
let observations = vec![
obs(450.0, Some(("Monolink", "Father Ocean (Ben Böhmer Remix)", 30.0))),
obs(
495.0,
Some((
"Monolink",
"Father Ocean (Ben Böhmer Remix (Live At The Roundhouse, London))",
290.0, // wildly different release → scattered implied start
)),
),
obs(540.0, Some(("Monolink", "Father Ocean (Ben Böhmer Remix)", 121.0))),
];
let out = consensus(&observations);
assert_eq!(out.len(), 1, "variants must fuse");
assert_eq!(out[0].votes, 3);
// Implieds are 420 / 205 / 419 — the 420±1 pair agrees, so the
// boundary comes from it, not from the junk one.
assert!((out[0].start_secs - 419.5).abs() < 1.0, "{}", out[0].start_secs);
}
/// A track played TWICE stays two occurrences — dedup would erase the
/// set's real shape.
#[test]
fn consensus_keeps_repeated_tracks_apart() {
let observations = vec![
obs(45.0, Some(("Artist", "Anthem", 30.0))),
obs(1800.0, Some(("Artist", "Anthem", 25.0))),
];
let out = consensus(&observations);
assert_eq!(out.len(), 2);
}
/// The ISRC rides with the SAME release whose artist/title label the
/// occurrence — the majority variant's — so identity stays coherent.
#[test]
fn consensus_carries_the_majority_variants_isrc() {
let mut first = obs(45.0, Some(("Bicep", "Glue", 40.0)));
first.rec.as_mut().unwrap().isrc = Some("GBCFB1700573".into());
let second = obs(90.0, Some(("Bicep", "Glue", 85.0)));
let out = consensus(&[first, second]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].isrc.as_deref(), Some("GBCFB1700573"));
}
/// The displayed identity is the cluster's most-heard exact variant,
/// not blindly the first window's — the first window sits in the
/// transition zone, where a remix's intro matches the original it
/// samples.
#[test]
fn consensus_prefers_the_majority_variant() {
let observations = vec![
obs(45.0, Some(("Hamdi", "Skanka", 30.0))),
obs(90.0, Some(("Hamdi", "Skanka (Kayzo Remix)", 75.0))),
obs(135.0, Some(("Hamdi", "Skanka (Kayzo Remix)", 120.0))),
];
let out = consensus(&observations);
assert_eq!(out.len(), 1);
assert_eq!(out[0].title, "Skanka (Kayzo Remix)");
assert_eq!(out[0].votes, 3);
}
fn occ_at(start: f64, artist: &str, title: &str, isrc: Option<&str>) -> Occurrence {
Occurrence {
start_secs: start,
artist: artist.into(),
title: title.into(),
isrc: isrc.map(str::to_string),
votes: 2,
}
}
fn dec(start: f64, artist: Option<&str>, title: &str) -> DeclaredRow {
DeclaredRow {
start_secs: start,
artist: artist.map(str::to_string),
title: title.into(),
}
}
/// The DJ's posted list names the VERSION: a declared qualifier
/// overrides a heard plain title and drops the heard ISRC (that anchor
/// belongs to the other release). A plain declared title never strips
/// a heard qualifier, and a different artist never matches.
#[test]
fn declared_tracklist_names_the_version() {
let mut confirmed = vec![occ_at(855.0, "Hamdi", "Skanka", Some("QZPLS2163210"))];
let declared = [dec(850.0, Some("Hamdi"), "Skanka (Kayzo Remix)")];
let notes = apply_declared_variants(&mut confirmed, &declared);
assert_eq!(confirmed[0].title, "Skanka (Kayzo Remix)");
assert!(
confirmed[0].isrc.is_none(),
"the heard ISRC must not anchor another release"
);
assert_eq!(notes.len(), 1);
let mut keep = vec![occ_at(100.0, "Artist", "Song (Club Mix)", None)];
assert!(
apply_declared_variants(&mut keep, &[dec(100.0, Some("Artist"), "Song")]).is_empty()
);
assert_eq!(keep[0].title, "Song (Club Mix)");
let mut other = vec![occ_at(100.0, "Artist", "Song", None)];
assert!(apply_declared_variants(
&mut other,
&[dec(100.0, Some("Someone Else"), "Song (X Remix)")]
)
.is_empty());
assert_eq!(other[0].title, "Song");
}
/// A declared row landing inside an unrecognized stretch stands in for
/// the anonymous ID row; a stretch nothing was declared for keeps the
/// honest ID.
#[test]
fn declared_rows_fill_recognition_gaps() {
let observations = vec![
obs(0.0, Some(("A", "One", 0.0))),
obs(45.0, None),
obs(90.0, None),
obs(135.0, None),
obs(180.0, Some(("B", "Two", 10.0))),
];
let occ = consensus(&observations);
let declared = [dec(70.0, Some("Secret"), "Weapon (VIP)")];
let (text, filled) = tracklist_lines(&occ, &observations, 200.0, &declared);
assert!(text.contains("1:10 Secret - Weapon (VIP)"), "{text}");
assert!(!text.contains("ID - ID"), "{text}");
assert_eq!(filled, ["Secret - Weapon (VIP)"]);
let (text2, filled2) = tracklist_lines(&occ, &observations, 200.0, &[]);
assert!(text2.contains("ID - ID"), "{text2}");
assert!(filled2.is_empty());
}
/// Long unrecognized stretches earn an ID row; short ones do not.
#[test]
fn gaps_become_id_rows() {
let observations = vec![
obs(0.0, Some(("A", "One", 0.0))),
obs(45.0, None),
obs(90.0, None),
obs(135.0, None),
obs(180.0, Some(("B", "Two", 10.0))),
];
let occ = consensus(&observations);
let (text, _) = tracklist_lines(&occ, &observations, 200.0, &[]);
assert!(text.contains("ID - ID"), "{text}");
let short_gap = vec![
obs(0.0, Some(("A", "One", 0.0))),
obs(45.0, None),
obs(90.0, Some(("B", "Two", 10.0))),
];
let occ2 = consensus(&short_gap);
let (text2, _) = tracklist_lines(&occ2, &short_gap, 110.0, &[]);
assert!(!text2.contains("ID - ID"), "{text2}");
}
/// The projected lines parse back through the real pasted-text parser
/// as a timestamped setlist.
#[test]
fn lines_round_trip_through_parse_text() {
let observations = vec![
obs(45.0, Some(("Bicep", "Glue", 40.0))),
obs(90.0, Some(("Bicep", "Glue", 85.0))),
];
let occ = consensus(&observations);
let (text, _) = tracklist_lines(&occ, &observations, 120.0, &[]);
let parsed = crate::playlist_formats::parse_text(text.as_bytes()).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].artist.as_deref(), Some("Bicep"));
assert!(parsed.entries[0].start_ms.is_some());
}
}