//! 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, } /// 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, 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, 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::() .split_whitespace() .collect::>() .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 { 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 { 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 { struct Cluster { key: (String, String), first_pos: f64, last_pos: f64, implieds: Vec, recs: Vec, } let mut clusters: Vec = 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 = 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) { 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 = Vec::new(); let mut fill_gap = |start: f64, end: f64, lines: &mut Vec<(f64, String)>, filled: &mut Vec| { 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 = 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::>() .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, String> { CANCEL.store(false, Ordering::Relaxed); let total_windows = (mono16k.len() / (STEP_SECS * shazam::SAMPLE_RATE)).max(1); let mut observations: Vec = 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 { 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, } /// 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, 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, ) -> Result { 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, } /// 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 { 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 { 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, // Where the declared rows came from, for the provenance warnings: // "the video's tracklist" or "the pasted tracklist". declared_src: &str, ) -> Result { // 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 = 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()); } }