1006 lines
38 KiB
Rust
1006 lines
38 KiB
Rust
//! 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());
|
||
}
|
||
}
|