//! Lyrics: sidecar files, embedded tags, and LRCLIB — resolved through one //! waterfall, cached in the library folder, offset-adjustable, and never in //! the way of playback. //! //! Source order (recording confidence first, timing detail second, per the //! lyrics research): a user paste or picked candidate wins outright; then a //! same-basename timed sidecar (.ttml before .lrc); then an embedded SYLT //! frame; then the word-level network catalog (the AMLL DB, by ISRC or a //! duration-validated name match); then LRCLIB's exact-signature lookup //! (which validates duration); //! then local plain text; then LRCLIB plain. Network results and user //! choices persist in `/.soulstream/lyrics/`, so they travel with //! the library. //! //! Nothing here runs on the audio thread; commands do file work in //! spawn_blocking and network work on the async runtime with timeouts. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; const LRCLIB_BASE: &str = "https://lrclib.net/api"; /// How long a genuine LRCLIB not-found is believed before asking again. const NOT_FOUND_TTL_SECS: i64 = 3 * 24 * 60 * 60; const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } // ---- payloads -------------------------------------------------------------- #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LyricLine { pub time_ms: u32, pub text: String, /// Word-level timing, when the SOURCE really carries it (enhanced-LRC /// word marks, word/syllable SYLT). Empty for line-level sources — the /// renderer then highlights whole lines and shows NO word sweep; word /// times are carried, never invented. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub words: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LyricWord { pub time_ms: u32, pub text: String, } /// What the frontend renders. `status`: "synced" | "plain" | "instrumental" | /// "none". `source` names where the winning document came from. #[derive(Debug, Clone, Serialize)] pub struct LyricsPayload { pub status: String, pub source: String, pub synced: Vec, pub plain: Option, pub offset_ms: i64, /// Automatic timing correction, milliseconds — nonzero only when the /// duration mismatch between the lyric source's recording and the local /// file is CORROBORATED by the file's own measured leading silence. /// Additive with the user offset; the UI shows it as its own chip. pub auto_offset_ms: i64, /// The user turned auto-sync off for this track. pub auto_sync_off: bool, } impl LyricsPayload { fn none() -> Self { LyricsPayload { status: "none".into(), source: String::new(), synced: Vec::new(), plain: None, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false, } } } /// One cached document (network result or user override). #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredDoc { source: String, #[serde(default)] synced: Vec, #[serde(default)] plain: Option, #[serde(default)] instrumental: bool, /// The duration of the RECORDING the source document was made against /// (LRCLIB reports it) — the anchor auto-sync compares the local file to. #[serde(default)] source_duration_secs: Option, } /// The per-track cache file. Local sources (sidecars, embedded tags) are /// deliberately NOT cached — they are fast to re-read and a changed sidecar /// should win immediately. The file holds what is expensive (network) or /// irreplaceable (user choice, offset). #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct CacheEntry { #[serde(default)] offset_ms: i64, #[serde(default)] user: Option, #[serde(default)] lrclib: Option, /// Word-level network doc (AMLL DB or NetEase yrc), with its own /// not-found clock — the word catalogs and LRCLIB miss independently. #[serde(default)] word: Option, #[serde(default)] word_not_found_at: Option, #[serde(default)] not_found_at: Option, #[serde(default)] auto_sync_off: bool, } // ---- cache ----------------------------------------------------------------- fn lyrics_dir(app: &tauri::AppHandle) -> Option { let cfg = crate::config::load(app); let dir = crate::config::ensure_library_data_dir(app, &cfg) .ok()? .join("lyrics"); std::fs::create_dir_all(&dir).ok()?; Some(dir) } /// Cache key: artist + title + duration bucket. Duration is part of identity /// because a live take shares its name with the studio recording while /// needing different timing; the 10-second bucket absorbs container rounding. fn cache_key(artist: &str, title: &str, duration_secs: Option) -> String { let clean = |s: &str| -> String { s.to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .take(80) .collect() }; let bucket = duration_secs.map(|d| (d / 10).to_string()).unwrap_or_else(|| "x".into()); format!("{}-{}-{}", clean(artist), clean(title), bucket) } fn cache_path(app: &tauri::AppHandle, key: &str) -> Option { Some(lyrics_dir(app)?.join(format!("{key}.json"))) } fn load_entry(app: &tauri::AppHandle, key: &str) -> CacheEntry { cache_path(app, key) .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn save_entry(app: &tauri::AppHandle, key: &str, entry: &CacheEntry) { let Some(path) = cache_path(app, key) else { return }; if let Ok(json) = serde_json::to_string(entry) { let temp = path.with_extension("json.writing"); if std::fs::write(&temp, json).is_ok() { let _ = std::fs::remove_file(&path); let _ = std::fs::rename(&temp, &path); } } } // ---- LRC parsing ----------------------------------------------------------- /// Parse LRC text into timed lines. Handles `[offset:±N]` (positive shifts /// lyrics earlier, the common convention), several timestamps on one line, /// two- and three-decimal fractions, bare `[mm:ss]`, metadata tags, BOM, and /// enhanced-LRC word marks (``), which are flattened to line text. /// Returns lines sorted by time; empty when nothing timed was found. pub fn parse_lrc(text: &str) -> Vec { let text = text.trim_start_matches('\u{feff}'); let mut offset_ms: i64 = 0; let mut lines: Vec = Vec::new(); for raw in text.lines() { let raw = raw.trim(); if raw.is_empty() { continue; } // Collect every leading [..] block. let mut rest = raw; let mut stamps: Vec = Vec::new(); let mut is_meta = false; while rest.starts_with('[') { let Some(end) = rest.find(']') else { break }; let inner = &rest[1..end]; if let Some(ms) = parse_lrc_timestamp(inner) { stamps.push(ms); } else if let Some(value) = inner.strip_prefix("offset:") { offset_ms = value.trim().replace('+', "").parse().unwrap_or(0); is_meta = true; } else { // [ar:…], [ti:…], [al:…] and friends. is_meta = true; } rest = &rest[end + 1..]; } if stamps.is_empty() { let _ = is_meta; continue; } let (line_text, words) = extract_words(rest.trim()); // Word marks are ABSOLUTE times, so they belong to exactly one // occurrence of a multi-stamped (repeated-chorus) line — the stamp // NEAREST the first word's time, not whichever was written first. let word_stamp = words.first().map(|w| { stamps .iter() .copied() .min_by_key(|s| (s - w.time_ms as i64).abs()) .unwrap_or(stamps[0]) }); for ms in &stamps { lines.push(LyricLine { time_ms: (*ms).max(0) as u32, text: line_text.clone(), words: if word_stamp == Some(*ms) { words.clone() } else { Vec::new() }, }); } } if offset_ms != 0 { for line in &mut lines { let shifted = line.time_ms as i64 - offset_ms; line.time_ms = shifted.max(0) as u32; for word in &mut line.words { let shifted = word.time_ms as i64 - offset_ms; word.time_ms = shifted.max(0) as u32; } } } lines.sort_by_key(|l| l.time_ms); // A file of timestamps with no words at all is not synced lyrics. if lines.iter().all(|l| l.text.is_empty()) { return Vec::new(); } lines } /// `mm:ss`, `mm:ss.xx`, `mm:ss.xxx` (minutes may exceed 59). None for /// anything else — which is how metadata tags are told apart. fn parse_lrc_timestamp(s: &str) -> Option { let (mins, rest) = s.split_once(':')?; if mins.is_empty() || mins.len() > 4 || !mins.chars().all(|c| c.is_ascii_digit()) { return None; } let (secs, frac) = match rest.split_once(['.', ':']) { Some((s, f)) => (s, Some(f)), None => (rest, None), }; if secs.len() != 2 || !secs.chars().all(|c| c.is_ascii_digit()) { return None; } let mins: i64 = mins.parse().ok()?; let secs: i64 = secs.parse().ok()?; if secs > 59 { return None; } let frac_ms: i64 = match frac { None => 0, Some(f) if f.len() == 2 && f.chars().all(|c| c.is_ascii_digit()) => { f.parse::().ok()? * 10 } Some(f) if f.len() == 3 && f.chars().all(|c| c.is_ascii_digit()) => f.parse().ok()?, Some(_) => return None, }; Some((mins * 60 + secs) * 1000 + frac_ms) } /// Enhanced-LRC word markers `<00:12.34>` — extracted as REAL word timing /// when the line carries a coherent set (two or more marked words, times /// monotonic), flattened away otherwise. The display text is identical in /// both cases: the marks removed and spacing collapsed. fn extract_words(s: &str) -> (String, Vec) { // Pass 1: split into (mark, following-raw-text) chunks. let mut chunks: Vec<(Option, String)> = vec![(None, String::new())]; let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c == '<' { let mut probe = chars.clone(); let mut inner = String::new(); let mut closed = false; for n in probe.by_ref() { if n == '>' { closed = true; break; } inner.push(n); if inner.len() > 12 { break; } } if closed { if let Some(ms) = parse_lrc_timestamp(&inner) { chars = probe; chunks.push((Some(ms), String::new())); continue; } } } chunks.last_mut().unwrap().1.push(c); } // Display: the raw text with the marks removed, spaces collapsed. let mut display = String::new(); for (_, raw) in &chunks { display.push_str(raw); } let mut collapsed = String::with_capacity(display.len()); let mut last_space = false; for c in display.chars() { if c == ' ' { if !last_space { collapsed.push(c); } last_space = true; } else { collapsed.push(c); last_space = false; } } let display = collapsed.trim().to_string(); // Untimed text before the first mark = partial coverage → display only. if !chunks[0].1.trim().is_empty() { return (display, Vec::new()); } // Pass 2: timed chunks become words, syllable fragments merging into the // previous word when neither side shows a whitespace boundary — the same // contract the SYLT path keeps, so "Karaoke" renders "Karaoke". let mut words: Vec = Vec::new(); let mut previous_raw = ""; for (mark, raw) in chunks.iter().skip(1) { let trimmed = raw.trim(); if trimmed.is_empty() { previous_raw = raw; continue; } let boundary = words.is_empty() || raw.starts_with(char::is_whitespace) || previous_raw.ends_with(char::is_whitespace); if boundary { words.push(LyricWord { time_ms: mark.unwrap_or(0).max(0) as u32, text: trimmed.to_string(), }); } else if let Some(last) = words.last_mut() { last.text.push_str(trimmed); } previous_raw = raw; } let monotonic = words.windows(2).all(|w| w[0].time_ms <= w[1].time_ms); if words.len() < 2 || !monotonic { return (display, Vec::new()); } (display, words) } /// ID3 SYLT content units arrive at whatever granularity the tagger chose — /// whole lines, words, or syllables — and the frame's declared unit is /// unreliable in practice. Shape them into display lines: /// - a unit beginning with a newline starts a new line (the de-facto /// convention for word/syllable SYLT); /// - without newlines, LONG units are already lines; /// - without newlines, short dense units split into lines on time gaps. /// Sub-line units become word timing; pieces merge into words on whitespace /// boundaries so syllabic content does not read "Ka ra o ke". fn lines_from_sylt(entries: &[(u32, String)]) -> Vec { if entries.is_empty() { return Vec::new(); } let has_newlines = entries .iter() .any(|(_, t)| t.starts_with('\n') || t.starts_with("\r\n")); let avg_len = entries .iter() .map(|(_, t)| t.trim().chars().count()) .sum::() / entries.len(); if !has_newlines && avg_len > 12 { return entries .iter() .map(|(ms, t)| LyricLine { time_ms: *ms, text: t.trim().to_string(), words: Vec::new(), }) .collect(); } let mut lines: Vec = Vec::new(); let mut words: Vec = Vec::new(); let mut previous_raw = String::new(); let flush_line = |words: &mut Vec, lines: &mut Vec| { if words.is_empty() { return; } let text = words .iter() .map(|w| w.text.as_str()) .collect::>() .join(" "); lines.push(LyricLine { time_ms: words[0].time_ms, text, words: if words.len() >= 2 { std::mem::take(words) } else { { words.clear(); Vec::new() } }, }); }; let mut last_ms = entries[0].0; for (ms, raw) in entries { let starts_line = raw.starts_with('\n') || raw.starts_with("\r\n") || (!has_newlines && *ms > last_ms + 1500 && !words.is_empty()); if starts_line { flush_line(&mut words, &mut lines); } let piece = raw.trim_start_matches(['\r', '\n']); let trimmed = piece.trim(); if !trimmed.is_empty() { // A piece with no whitespace boundary continues the previous // word (syllabic SYLT); a boundary starts a new one. let boundary = starts_line || piece.starts_with(char::is_whitespace) || previous_raw.ends_with(char::is_whitespace) || words.is_empty(); if boundary { words.push(LyricWord { time_ms: *ms, text: trimmed.to_string(), }); } else if let Some(last) = words.last_mut() { last.text.push_str(trimmed); } } previous_raw = piece.to_string(); last_ms = *ms; } flush_line(&mut words, &mut lines); lines } // ---- TTML (Apple-style timed text) ----------------------------------------- /// Parse a TTML lyric document (the Apple Music / AMLL export format) into /// timed lines. `

` is a line; `` inside it is a /// word, and adjacent spans with NO whitespace between them are syllables of /// one word and merge (the SYLT rule, so "Coun|try" never reads "Coun try"). /// Word timing is kept only when every visible token carries it and times /// are monotonic — the renderer draws `words`, not `text`, in word mode, so /// a partial set would silently drop the tokens it skipped. /// `ttm:role="x-bg"` subtrees (background/adlib vocals) are dropped whole: /// there is no background layer to put them on, and folded inline their /// overlapping times would corrupt the main line. Line-timed documents /// (`

` text with no timed spans) come through as plain synced lines. /// Returns lines sorted by time; empty when nothing timed was found. pub fn parse_ttml(text: &str) -> Vec { use quick_xml::events::Event; let text = text.trim_start_matches('\u{feff}'); if !text.contains(", text: String, } let mut lines: Vec = Vec::new(); let mut in_body = false; let mut in_p = false; let mut p_begin: Option = None; let mut tokens: Vec = Vec::new(); let mut boundary = true; // the next visible text starts a new token // Open begins, innermost last — text takes the nearest timed one. let mut span_stack: Vec> = Vec::new(); loop { let event = match reader.read_event() { Ok(event) => event, // Malformed XML: refuse the document, never guess at timing. Err(_) => return Vec::new(), }; match event { Event::Eof => break, Event::Start(e) => match e.local_name().as_ref() { b"body" => in_body = true, b"p" if in_body => { in_p = true; p_begin = attr_local(&e, b"begin").as_deref().and_then(parse_ttml_clock); tokens = Vec::new(); boundary = true; span_stack.clear(); } b"span" if in_p => { if attr_local(&e, b"role").is_some() { // A role names an auxiliary layer — x-bg backing // vocals, x-translation, x-roman romanization — and // none of them belong inline in the main line (they // sit adjacent to the last word with no whitespace, // so kept they GLUE onto it: "close别靠近"). Main // word spans carry no role. Consume the whole // subtree, its own matching included. let mut depth = 1usize; loop { match reader.read_event() { Ok(Event::Start(s)) if s.local_name().as_ref() == b"span" => { depth += 1; } Ok(Event::End(s)) if s.local_name().as_ref() == b"span" => { depth -= 1; if depth == 0 { break; } } Ok(Event::Eof) | Err(_) => return Vec::new(), _ => {} } } } else { span_stack .push(attr_local(&e, b"begin").as_deref().and_then(parse_ttml_clock)); } } _ => {} }, Event::End(e) => match e.local_name().as_ref() { b"body" => in_body = false, b"p" if in_p => { in_p = false; let tokens = std::mem::take(&mut tokens); let text = tokens .iter() .map(|t| t.text.as_str()) .collect::>() .join(" "); // A p with no begin anywhere belongs to an untimed doc. let time = p_begin.or_else(|| tokens.iter().find_map(|t| t.time)); if let (false, Some(time)) = (text.is_empty(), time) { let timed: Vec = tokens.iter().filter_map(|t| t.time).collect(); let monotonic = timed.windows(2).all(|w| w[0] <= w[1]); let words = if timed.len() == tokens.len() && tokens.len() >= 2 && monotonic { tokens .iter() .map(|t| LyricWord { time_ms: t.time.unwrap_or(0).max(0) as u32, text: t.text.clone(), }) .collect() } else { Vec::new() }; lines.push(LyricLine { time_ms: time.max(0) as u32, text, words, }); } } b"span" if in_p => { span_stack.pop(); } _ => {} }, Event::Empty(e) => { //
is a boundary; an empty timed span has nothing to show. if in_p && e.local_name().as_ref() == b"br" { boundary = true; } } Event::Text(t) => { if !in_p { continue; } let Ok(raw) = t.unescape() else { continue }; let span_time = span_stack.iter().rev().find_map(|t| *t); let mut rest = raw.as_ref(); while !rest.is_empty() { if rest.starts_with(char::is_whitespace) { boundary = true; rest = rest.trim_start(); } else { let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let piece = &rest[..end]; if boundary || tokens.is_empty() { tokens.push(Token { time: span_time, text: piece.to_string(), }); } else if let Some(last) = tokens.last_mut() { last.text.push_str(piece); // An untimed lead-in (stray punctuation before the // first timed syllable) adopts the syllable's time. if last.time.is_none() { last.time = span_time; } } boundary = false; rest = &rest[end..]; } } } _ => {} } } lines.sort_by_key(|l| l.time_ms); lines } /// Attribute by LOCAL name — Apple prefixes freely (`ttm:role`, `itunes:key`), /// and the prefix spelling is the document's choice, not part of the name. fn attr_local(e: &quick_xml::events::BytesStart, name: &[u8]) -> Option { e.attributes().flatten().find_map(|a| { (a.key.local_name().as_ref() == name) .then(|| a.unescape_value().ok().map(|v| v.into_owned())) .flatten() }) } /// TTML time expressions as lyric files actually write them: bare seconds /// ("7.759"), offset-time with a unit ("7.759s", "1500ms"), and clock time /// ("0:07.759", "00:01:15.500"). Frame-based clock values (a fourth /// component) are refused rather than misread. fn parse_ttml_clock(s: &str) -> Option { let s = s.trim(); if s.is_empty() { return None; } let scaled = |v: &str, scale: f64| -> Option { let n: f64 = v.trim().parse().ok()?; (n.is_finite() && n >= 0.0).then(|| (n * scale).round() as i64) }; // "ms" before "s" — every millisecond value also ends in 's'. if let Some(v) = s.strip_suffix("ms") { return scaled(v, 1.0); } if let Some(v) = s.strip_suffix('s') { return scaled(v, 1000.0); } if let Some(v) = s.strip_suffix('h') { return scaled(v, 3_600_000.0); } if let Some(v) = s.strip_suffix('m') { return scaled(v, 60_000.0); } let int = |v: &str| -> Option { (!v.is_empty() && v.len() <= 4 && v.chars().all(|c| c.is_ascii_digit())) .then(|| v.parse().ok()) .flatten() }; let parts: Vec<&str> = s.split(':').collect(); match parts.as_slice() { [secs] => scaled(secs, 1000.0), [mins, secs] => Some(int(mins)? * 60_000 + scaled(secs, 1000.0)?), [hours, mins, secs] => { Some(int(hours)? * 3_600_000 + int(mins)? * 60_000 + scaled(secs, 1000.0)?) } _ => None, } } /// Timed lines are trusted only when they behave like a song: monotonic /// (post-sort they are), inside the track when its duration is known, and /// not all bunched at zero. fn plausible_timing(lines: &[LyricLine], duration_secs: Option) -> bool { if lines.len() < 2 { return !lines.is_empty(); } if let Some(duration) = duration_secs { let limit_ms = (duration as u64 + 15) * 1000; let inside = lines .iter() .filter(|l| (l.time_ms as u64) < limit_ms) .count(); // A handful of trailing credits past the end is tolerable; a document // mostly outside the track belongs to another recording. if inside * 2 < lines.len() { return false; } } lines.last().map(|l| l.time_ms).unwrap_or(0) > 0 } // ---- sidecars -------------------------------------------------------------- /// `Track.flac` → first existing of `Track.ttml` (word-level Apple-style /// timed text outranks line-level LRC), `Track.lrc`, `Track.en.lrc` (any /// language suffix), then `Track.txt` for plain. fn sidecar_lyrics(audio_path: &str) -> (Option>, Option) { let path = Path::new(audio_path); let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else { return (None, None); }; let Some(dir) = path.parent() else { return (None, None) }; let mut timed = None; let ttml = dir.join(format!("{stem}.ttml")); if let Ok(text) = std::fs::read_to_string(&ttml) { let lines = parse_ttml(&text); if !lines.is_empty() { timed = Some(lines); } } if timed.is_none() { let exact = dir.join(format!("{stem}.lrc")); if let Ok(text) = std::fs::read_to_string(&exact) { let lines = parse_lrc(&text); if !lines.is_empty() { timed = Some(lines); } } } if timed.is_none() { // Language-suffixed variants: Track.en.lrc, Track.ja.lrc… The suffix // must LOOK like a language tag (2–3 letters): "Song.Live.lrc" beside // "Song.Live.flac" is a DIFFERENT recording's lyrics, not a variant. if let Ok(entries) = std::fs::read_dir(dir) { let prefix = format!("{}.", stem.to_lowercase()); for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_lowercase(); let suffix = name .strip_prefix(&prefix) .and_then(|rest| rest.strip_suffix(".lrc")); let is_lang = suffix.is_some_and(|s| { (2..=3).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphabetic()) }); if is_lang { if let Ok(text) = std::fs::read_to_string(entry.path()) { let lines = parse_lrc(&text); if !lines.is_empty() { timed = Some(lines); break; } } } } } } let mut plain = None; let txt = dir.join(format!("{stem}.txt")); if let Ok(text) = std::fs::read_to_string(&txt) { let trimmed = text.trim(); // A .txt beside the audio can be anything; only take it when it looks // like lyrics — several lines, no path-ish or tracker-log content. let line_count = trimmed.lines().count(); if line_count >= 4 && trimmed.len() < 64 * 1024 { plain = Some(trimmed.to_string()); } } (timed, plain) } // ---- LRCLIB ---------------------------------------------------------------- #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LrclibRecord { pub id: i64, #[serde(default)] pub track_name: String, #[serde(default)] pub artist_name: String, #[serde(default)] pub album_name: String, #[serde(default)] pub duration: f64, #[serde(default)] pub instrumental: bool, #[serde(default)] pub plain_lyrics: Option, #[serde(default)] pub synced_lyrics: Option, } fn http_client() -> Result { reqwest::Client::builder() .user_agent("SoulStream/0.1 (https://soulstream.local)") .timeout(FETCH_TIMEOUT) .build() .map_err(|e| e.to_string()) } async fn lrclib_get( artist: &str, title: &str, album: Option<&str>, duration_secs: Option, ) -> Result, String> { let client = http_client()?; let mut query: Vec<(&str, String)> = vec![ ("artist_name", artist.to_string()), ("track_name", title.to_string()), ]; if let Some(album) = album { query.push(("album_name", album.to_string())); } if let Some(duration) = duration_secs { query.push(("duration", duration.to_string())); } let response = client .get(format!("{LRCLIB_BASE}/get")) .query(&query) .send() .await .map_err(|e| e.to_string())?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } if !response.status().is_success() { return Err(format!("lrclib get: HTTP {}", response.status())); } response.json().await.map(Some).map_err(|e| e.to_string()) } async fn lrclib_search(artist: &str, title: &str) -> Result, String> { let client = http_client()?; let response = client .get(format!("{LRCLIB_BASE}/search")) .query(&[("track_name", title), ("artist_name", artist)]) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("lrclib search: HTTP {}", response.status())); } response.json().await.map_err(|e| e.to_string()) } async fn lrclib_by_id(id: i64) -> Result { let client = http_client()?; let response = client .get(format!("{LRCLIB_BASE}/get/{id}")) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("lrclib get/{id}: HTTP {}", response.status())); } response.json().await.map_err(|e| e.to_string()) } fn norm(s: &str) -> String { s.to_lowercase() .chars() .filter(|c| c.is_alphanumeric()) .collect() } fn record_to_doc(record: &LrclibRecord) -> StoredDoc { let synced = record .synced_lyrics .as_deref() .map(parse_lrc) .unwrap_or_default(); StoredDoc { source: "lrclib".into(), synced, plain: record .plain_lyrics .as_ref() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), instrumental: record.instrumental, source_duration_secs: (record.duration > 0.0).then_some(record.duration), } } /// Auto-select a search candidate only when it is unmistakably this /// recording: normalized title and artist agree and any known duration is /// within three seconds. Ambiguity is the user's call, never a guess. fn confident_candidate<'a>( records: &'a [LrclibRecord], artist: &str, title: &str, duration_secs: Option, ) -> Option<&'a LrclibRecord> { let (nt, na) = (norm(title), norm(artist)); records.iter().find(|r| { norm(&r.track_name) == nt && (na.is_empty() || norm(&r.artist_name) == na) && match duration_secs { Some(d) if r.duration > 0.0 => (r.duration - d as f64).abs() <= 3.0, _ => true, } && (r.synced_lyrics.is_some() || r.plain_lyrics.is_some() || r.instrumental) }) } // ---- the word-level network catalog ---------------------------------------- // // The AMLL community TTML database (github: amll-dev/amll-ttml-db), feeding // the cached `word` slot. Two ways in, strongest first: // - the file's own ISRC tag — exact recording identity, any hosted format; // - normalized title+artist equality (LRCLIB's own confidence rule), accepted // ONLY when the recording the document names (its appleMusicId, resolved // through the free iTunes lookup) has the local track's duration — a name // alone never places a record. The document's own `` is NOT that // anchor: it marks the last lyric line's end, so every song with an // instrumental outro would fail against the real track length. // (NetEase yrc was tried and removed: its public endpoints geo-block this // side of the world with encrypted `"abroad"` responses.) const AMLL_BASE: &str = "https://raw.githubusercontent.com/amll-dev/amll-ttml-db/refs/heads/main"; /// The DB's four ID namespaces, most-populated first. const AMLL_PLATFORMS: [&str; 4] = ["ncm", "am", "spotify", "qq"]; const AMLL_INDEX_TTL_SECS: u64 = 24 * 60 * 60; /// Acceptance gate for name-matched documents, seconds — the same tolerance /// `confident_candidate` holds LRCLIB to. const AMLL_DURATION_TOLERANCE_SECS: f64 = 3.0; /// One platform's `index.jsonl`, from the daily disk cache when fresh, the /// network otherwise — falling back to a stale cache copy when the fetch /// fails, so an offline day keeps yesterday's answers. async fn amll_index(app: &tauri::AppHandle, platform: &str) -> Result { let cache = lyrics_dir(app).map(|d| d.join("amll-index").join(format!("{platform}.jsonl"))); let read_cache = |path: PathBuf, max_age: Option| async move { tauri::async_runtime::spawn_blocking(move || { let meta = std::fs::metadata(&path).ok()?; if let Some(max_age) = max_age { let age = meta.modified().ok()?.elapsed().ok()?; if age.as_secs() >= max_age { return None; } } std::fs::read_to_string(&path).ok() }) .await .ok() .flatten() }; if let Some(path) = cache.clone() { if let Some(text) = read_cache(path, Some(AMLL_INDEX_TTL_SECS)).await { return Ok(text); } } let fetch = async { let client = reqwest::Client::builder() .user_agent("SoulStream/0.1 (https://soulstream.local)") // The indexes run to megabytes — the 10 s document timeout is // too tight for them on a slow link. .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| e.to_string())?; let response = client .get(format!("{AMLL_BASE}/{platform}-lyrics/index.jsonl")) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("amll index {platform}: HTTP {}", response.status())); } response.text().await.map_err(|e| e.to_string()) }; match fetch.await { Ok(text) => { if let Some(path) = cache { let stored = text.clone(); let _ = tauri::async_runtime::spawn_blocking(move || { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&path, stored); }) .await; } Ok(text) } Err(e) => { if let Some(path) = cache { if let Some(stale) = read_cache(path, None).await { log::debug!("amll index {platform}: fetch failed ({e}), using stale cache"); return Ok(stale); } } Err(e) } } } fn canon_id(s: &str) -> String { s.chars() .filter(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_uppercase()) .collect() } /// One nominated index record. `apple_id` is the record's own appleMusicId — /// the handle a name match is duration-validated through. #[derive(Debug, PartialEq)] struct AmllCandidate { id: String, exact: bool, apple_id: Option, } /// Candidates from one platform's index. Records look like /// `{"id":"…","metadata":[["isrc",["USUM71703861"]],["musicName",[…]], /// ["artists",[…]],["appleMusicId",[…]]…]}`. An exact ISRC hit (compared the /// way `tagger::read_isrc` normalizes) wins alone and immediately. Otherwise, /// when name matching is allowed (the caller knows the local duration, so a /// candidate CAN be validated), normalized title+artist equality — the LRCLIB /// confidence rule — nominates up to three; every one still has to survive /// the iTunes duration gate in `amll_lookup` before it counts. fn amll_index_candidates( index_jsonl: &str, isrc: Option<&str>, title: &str, artist: &str, allow_name_matches: bool, ) -> Vec { let want_isrc = isrc.map(canon_id).filter(|s| !s.is_empty()); let (nt, na) = (norm(title), norm(artist)); let title_probe = canon_id(title); let name_matching = allow_name_matches && !nt.is_empty(); let mut named: Vec = Vec::new(); for line in index_jsonl.lines() { // Cheap literal pre-filter before any JSON work — on the CANONICAL // form of the line, so a stored "gb-ahs-16-00001" still pre-passes. let line_canon: String = line .chars() .filter(|c| c.is_ascii_alphanumeric()) .map(|c| c.to_ascii_uppercase()) .collect(); let isrc_possible = want_isrc .as_ref() .is_some_and(|want| line_canon.contains(want.as_str())); let name_possible = name_matching && line_canon.contains(title_probe.as_str()); if !isrc_possible && !name_possible { continue; } let Ok(record) = serde_json::from_str::(line) else { continue; }; let Some(id) = record.get("id").and_then(|i| i.as_str()) else { continue; }; let metadata = record.get("metadata").and_then(|m| m.as_array()); let values = |key: &str| -> Vec<&str> { metadata .into_iter() .flatten() .filter_map(|pair| { (pair.get(0).and_then(|k| k.as_str()) == Some(key)) .then(|| pair.get(1).and_then(|v| v.as_array())) .flatten() }) .flatten() .filter_map(|v| v.as_str()) .collect() }; if isrc_possible { let want = want_isrc.as_deref().unwrap_or_default(); if values("isrc").iter().any(|v| canon_id(v) == want) { return vec![AmllCandidate { id: id.to_string(), exact: true, apple_id: values("appleMusicId").first().map(|s| s.to_string()), }]; } } if name_possible && named.len() < 3 { let title_hit = values("musicName").iter().any(|v| norm(v) == nt); let artist_hit = na.is_empty() || values("artists").iter().any(|a| norm(a) == na); if title_hit && artist_hit { named.push(AmllCandidate { id: id.to_string(), exact: false, apple_id: values("appleMusicId").first().map(|s| s.to_string()), }); } } } named } /// The exact recording's duration via the free, auth-less iTunes lookup — /// the anchor that validates a name-matched AMLL document. `Ok(None)` means /// iTunes has no verdict for this id (unknown, or no trackTimeMillis) — the /// candidate is unverifiable, not wrong. async fn itunes_track_duration_secs(apple_id: &str) -> Result, String> { let client = http_client()?; let response = client .get("https://itunes.apple.com/lookup") .query(&[("id", apple_id)]) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("itunes lookup {apple_id}: HTTP {}", response.status())); } let body: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; Ok(body .get("results") .and_then(|r| r.as_array()) .and_then(|r| r.first()) .and_then(|track| track.get("trackTimeMillis")) .and_then(|ms| ms.as_i64()) .map(|ms| ms as f64 / 1000.0)) } /// The AMLL DB end to end. An ISRC hit accepts any hosted format that /// carries word timing (.ttml, then .yrc, then .lrc with enhanced marks). A /// name hit is TTML-only and stands or falls on the iTunes-resolved duration /// of the recording its appleMusicId names agreeing with the local track — /// validated BEFORE the document is even fetched. Only documents with at /// least one word-timed line land in the word slot — a line-level document /// here would add nothing over LRCLIB. async fn amll_lookup( app: &tauri::AppHandle, isrc: Option<&str>, artist: &str, title: &str, duration_secs: Option, ) -> Result, String> { let has_words = |lines: &[LyricLine]| lines.iter().any(|l| !l.words.is_empty()); for platform in AMLL_PLATFORMS { let index = amll_index(app, platform).await?; let candidates = amll_index_candidates(&index, isrc, title, artist, duration_secs.is_some()); for candidate in candidates { let mut source_duration_secs = None; if !candidate.exact { // A name match stands only on duration agreement with the // recording the document itself names. let (Some(local), Some(apple_id)) = (duration_secs, candidate.apple_id.as_deref()) else { continue; }; let Some(doc_secs) = itunes_track_duration_secs(apple_id).await? else { continue; }; if (doc_secs - local as f64).abs() > AMLL_DURATION_TOLERANCE_SECS { continue; } source_duration_secs = Some(doc_secs); } let formats: &[&str] = if candidate.exact { &["ttml", "yrc", "lrc"] } else { &["ttml"] }; for format in formats.iter().copied() { let client = http_client()?; let response = client .get(format!( "{AMLL_BASE}/{platform}-lyrics/{}.{format}", candidate.id )) .send() .await .map_err(|e| e.to_string())?; if response.status() == reqwest::StatusCode::NOT_FOUND { // Index/file drift, or a format this entry doesn't ship. continue; } if !response.status().is_success() { return Err(format!( "amll {platform}/{}.{format}: HTTP {}", candidate.id, response.status() )); } let text = response.text().await.map_err(|e| e.to_string())?; let lines = match format { "ttml" => parse_ttml(&text), "yrc" => parse_yrc(&text), _ => parse_lrc(&text), }; if lines.is_empty() || !has_words(&lines) { continue; } return Ok(Some(StoredDoc { source: "amll".into(), synced: lines, plain: None, instrumental: false, source_duration_secs, })); } } } Ok(None) } /// The yrc word-timing format (NetEase's, but reaching us as the `.yrc` /// files the AMLL DB hosts): `[lineStart,lineDur](wordStart,wordDur,0)text /// (…)text…` per line, with JSON credit lines (`{"t":…}`) interleaved. /// Pieces merge into words on whitespace boundaries exactly like the SYLT /// path — which also means CJK yrc (per-character pieces, no spaces) /// deliberately collapses to line-level highlight rather than gluing margins /// between every character. Word timing is kept only when the words /// reconstruct the visible line and run monotonic; anything else degrades to /// the line-level display. pub fn parse_yrc(text: &str) -> Vec { let mut lines: Vec = Vec::new(); for raw in text.lines() { let raw = raw.trim(); // Credit/metadata lines are JSON objects; lyric lines start with `[`. if raw.is_empty() || !raw.starts_with('[') { continue; } let Some((head, body)) = raw[1..].split_once(']') else { continue; }; let line_start: Option = head.split(',').next().and_then(|v| v.trim().parse().ok()); let mut pieces = body.split('('); let mut display = String::from(pieces.next().unwrap_or("")); let mut words: Vec = Vec::new(); let mut previous_raw = String::new(); let mut clean = true; // every piece parsed and carried a time for piece in pieces { let Some((meta, txt)) = piece.split_once(')') else { // A stray `(` inside lyric text — keep the text, drop word mode. display.push('('); display.push_str(piece); clean = false; continue; }; let start: Option = meta.split(',').next().and_then(|v| v.trim().parse().ok()); display.push_str(txt); let trimmed = txt.trim(); if trimmed.is_empty() { continue; } let Some(ms) = start else { clean = false; continue; }; let boundary = words.is_empty() || previous_raw.ends_with(char::is_whitespace) || txt.starts_with(char::is_whitespace); if boundary { words.push(LyricWord { time_ms: ms.max(0) as u32, text: trimmed.to_string(), }); } else if let Some(last) = words.last_mut() { last.text.push_str(trimmed); } previous_raw = txt.to_string(); } let display = display.split_whitespace().collect::>().join(" "); if display.is_empty() { continue; } let Some(time) = line_start.or_else(|| words.first().map(|w| w.time_ms as i64)) else { continue; }; let monotonic = words.windows(2).all(|w| w[0].time_ms <= w[1].time_ms); let reconstructs = words .iter() .map(|w| w.text.as_str()) .collect::>() .join(" ") == display; let words = if clean && words.len() >= 2 && monotonic && reconstructs { words } else { Vec::new() }; lines.push(LyricLine { time_ms: time.max(0) as u32, text: display, words, }); } lines.sort_by_key(|l| l.time_ms); lines } // ---- the waterfall --------------------------------------------------------- /// Corroborated automatic offset between a source document's recording and /// the local file, milliseconds (positive = lyrics later). /// /// The only evidence strong enough to act on automatically is AGREEMENT /// between two independent measurements: /// - the duration delta (local file vs the recording LRCLIB timed against); /// - the local file's own measured leading silence. /// A local file that is ~4 s longer AND carries ~4 s of leading silence has /// a padded intro — shift the lyrics by it. A shorter local file (trimmed /// lead-in) is accepted only when the local lead-in is essentially zero and /// the first cue leaves room. Anything else stays at zero: a wrong automatic /// shift is worse than the nudge buttons. async fn compute_auto_offset( path: Option<&str>, local_duration_secs: Option, source_duration_secs: Option, first_line_ms: Option, ) -> i64 { let (Some(path), Some(local), Some(source)) = (path, local_duration_secs, source_duration_secs) else { return 0; }; let delta_ms = ((local as f64 - source) * 1000.0).round() as i64; if delta_ms.abs() < 1200 || delta_ms.abs() > 15_000 { return 0; } // Only the POSITIVE case is decidable from local evidence: extra length // + matching measured lead-in silence = a padded intro, shift by it. A // SHORTER local file proves nothing about the start — near-zero lead-in // is the norm for every file, and a radio edit loses its length at the // END, where a start-shift would wreck the timing. That case stays 0. if delta_ms < 0 { let _ = first_line_ms; return 0; } let path = path.to_string(); let silence_ms = tauri::async_runtime::spawn_blocking(move || { player::vocals::leading_silence_ms(Path::new(&path)) }) .await .ok() .flatten(); match silence_ms { Some(silence) if (silence as i64 - delta_ms).abs() <= 1500 => delta_ms, _ => 0, } } fn payload_from(doc: &StoredDoc, offset_ms: i64, duration_secs: Option) -> LyricsPayload { if doc.instrumental { return LyricsPayload { status: "instrumental".into(), source: doc.source.clone(), synced: Vec::new(), plain: None, offset_ms, ..LyricsPayload::none() }; } // The plausibility gate protects against WRONG automatic candidates. A // document the user pasted or picked themselves is their explicit call — // second-guessing it would silently discard their own work. let trusted = matches!(doc.source.as_str(), "user" | "picked"); if !doc.synced.is_empty() && (trusted || plausible_timing(&doc.synced, duration_secs)) { return LyricsPayload { status: "synced".into(), source: doc.source.clone(), synced: doc.synced.clone(), plain: doc.plain.clone(), offset_ms, ..LyricsPayload::none() }; } if let Some(plain) = &doc.plain { return LyricsPayload { status: "plain".into(), source: doc.source.clone(), synced: Vec::new(), plain: Some(plain.clone()), offset_ms, ..LyricsPayload::none() }; } LyricsPayload::none() } /// Resolve lyrics for one playing track. Pure lookup — never blocks playback, /// never writes anything except the network cache. `refresh` bypasses the /// cached network verdict (the pane's retry action). #[tauri::command] pub async fn lyrics_get( app: tauri::AppHandle, title: String, artist: String, album: Option, duration_secs: Option, path: Option, refresh: Option, ) -> Result { let title = title.trim().to_string(); let artist = artist.trim().to_string(); if title.is_empty() { return Ok(LyricsPayload::none()); } let key = cache_key(&artist, &title, duration_secs); let entry = { let app = app.clone(); let key = key.clone(); tauri::async_runtime::spawn_blocking(move || load_entry(&app, &key)) .await .map_err(|e| e.to_string())? }; let offset = entry.offset_ms; // 1. The user's own document (paste or picked candidate) wins outright. if let Some(user) = &entry.user { let payload = payload_from(user, offset, duration_secs); if payload.status != "none" { return Ok(finish_synced( &app, payload, entry.auto_sync_off, path.as_deref(), duration_secs, user.source_duration_secs, ) .await); } } // 2. Local files: timed sidecar, then embedded SYLT. let local = match path.clone() { Some(p) if !p.is_empty() => { tauri::async_runtime::spawn_blocking(move || { let (sidecar_timed, sidecar_plain) = sidecar_lyrics(&p); let embedded = tagger::read_lyrics(Path::new(&p)).ok(); (sidecar_timed, sidecar_plain, embedded) }) .await .map_err(|e| e.to_string())? } _ => (None, None, None), }; let (sidecar_timed, sidecar_plain, embedded) = local; if let Some(lines) = sidecar_timed { if plausible_timing(&lines, duration_secs) { return Ok(LyricsPayload { status: "synced".into(), source: "sidecar".into(), synced: lines, plain: None, offset_ms: offset, ..LyricsPayload::none() }); } } if let Some(embedded) = &embedded { if !embedded.synced.is_empty() { let lines = lines_from_sylt(&embedded.synced); if plausible_timing(&lines, duration_secs) { return Ok(LyricsPayload { status: "synced".into(), source: "embedded".into(), synced: lines, plain: None, offset_ms: offset, ..LyricsPayload::none() }); } } } // 3. The word-level network catalog: the AMLL DB, by the file's own ISRC // tag when it has one, else by duration-validated name match. Word timing // outranks LRCLIB's line timing and loses to everything local above. // Cached beside the LRCLIB verdict with its own not-found clock. let refresh = refresh.unwrap_or(false); let mut word_doc = entry.word.clone(); let word_miss_fresh = entry .word_not_found_at .is_some_and(|at| now_secs() - at < NOT_FOUND_TTL_SECS); if refresh || (word_doc.is_none() && !word_miss_fresh) { let isrc = match path.clone() { Some(p) if !p.is_empty() => { tauri::async_runtime::spawn_blocking(move || { tagger::read_isrc(Path::new(&p)).ok().flatten() }) .await .map_err(|e| e.to_string())? } _ => None, }; match amll_lookup(&app, isrc.as_deref(), &artist, &title, duration_secs).await { Ok(fetched) => { word_doc = fetched.clone(); let app2 = app.clone(); let key2 = key.clone(); // Merge into a FRESH read, for the same reason the LRCLIB // write below does: user actions during the round-trip live. tauri::async_runtime::spawn_blocking(move || { let mut current = load_entry(&app2, &key2); match fetched { Some(doc) => { current.word = Some(doc); current.word_not_found_at = None; } None => current.word_not_found_at = Some(now_secs()), } save_entry(&app2, &key2, ¤t); }) .await .map_err(|e| e.to_string())?; } Err(e) => { // Transient: LRCLIB below still gets its turn, and the next // resolve simply tries the word catalogs again. log::debug!("word-level lyrics failed for {artist} — {title}: {e}"); } } } if let Some(doc) = &word_doc { let payload = payload_from(doc, offset, duration_secs); if payload.status == "synced" { return Ok(finish_synced( &app, payload, entry.auto_sync_off, path.as_deref(), duration_secs, doc.source_duration_secs, ) .await); } } // 4. LRCLIB — cached verdict first, network on a miss. `refresh` bypasses // BOTH cached states, hit and not-found — "look again" must actually look. let mut lrclib_doc = entry.lrclib.clone(); let not_found_fresh = entry .not_found_at .is_some_and(|at| now_secs() - at < NOT_FOUND_TTL_SECS); if refresh || (lrclib_doc.is_none() && !not_found_fresh) { let fetched = match lrclib_get(&artist, &title, album.as_deref(), duration_secs).await { Ok(Some(record)) => Some(record), Ok(None) => match lrclib_search(&artist, &title).await { Ok(records) => { confident_candidate(&records, &artist, &title, duration_secs).cloned() } Err(e) => { // A transport failure is NOT a not-found — caching it for // three days would hide lyrics that exist. Fall back like // the /get error path and try again next time. log::debug!("lyrics search failed for {artist} — {title}: {e}"); let mut fallback = LyricsPayload::none(); if let Some(plain) = local_plain(&sidecar_plain, &embedded) { fallback = LyricsPayload { status: "plain".into(), source: plain.1.into(), synced: Vec::new(), plain: Some(plain.0), offset_ms: offset, ..LyricsPayload::none() }; } return Ok(fallback); } }, Err(e) => { // Transient failure: report none WITHOUT caching a not-found, // so the next track change simply tries again. log::debug!("lyrics get failed for {artist} — {title}: {e}"); let mut fallback = LyricsPayload::none(); if let Some(plain) = local_plain(&sidecar_plain, &embedded) { fallback = LyricsPayload { status: "plain".into(), source: plain.1.into(), synced: Vec::new(), plain: Some(plain.0), offset_ms: offset, ..LyricsPayload::none() }; } return Ok(fallback); } }; let fetched_doc = fetched.as_ref().map(record_to_doc); lrclib_doc = fetched_doc.clone(); let app2 = app.clone(); let key2 = key.clone(); // Merge into a FRESH read of the entry, not the snapshot from before // the network round-trip — a pick, paste or offset the user made // during those seconds must survive this write. tauri::async_runtime::spawn_blocking(move || { let mut current = load_entry(&app2, &key2); match fetched_doc { Some(doc) => { current.lrclib = Some(doc); current.not_found_at = None; } None => current.not_found_at = Some(now_secs()), } save_entry(&app2, &key2, ¤t); }) .await .map_err(|e| e.to_string())?; } if let Some(doc) = &lrclib_doc { let payload = payload_from(doc, offset, duration_secs); // A synced or instrumental verdict is authoritative; a plain-only one // still yields to a local plain source (it is the same words, and the // local copy may be the corrected one). if payload.status == "synced" || payload.status == "instrumental" { return Ok(finish_synced( &app, payload, entry.auto_sync_off, path.as_deref(), duration_secs, doc.source_duration_secs, ) .await); } if payload.status == "plain" && local_plain(&sidecar_plain, &embedded).is_none() { return Ok(payload); } } // 5. Local plain text. if let Some((text, source)) = local_plain(&sidecar_plain, &embedded) { return Ok(LyricsPayload { status: "plain".into(), source: source.into(), synced: Vec::new(), plain: Some(text), offset_ms: offset, ..LyricsPayload::none() }); } if let Some(doc) = &lrclib_doc { let payload = payload_from(doc, offset, duration_secs); if payload.status != "none" { return Ok(payload); } } Ok(LyricsPayload::none()) } /// Stamp the auto-sync fields onto a winning synced payload. async fn finish_synced( app: &tauri::AppHandle, mut payload: LyricsPayload, auto_sync_off: bool, path: Option<&str>, duration_secs: Option, source_duration_secs: Option, ) -> LyricsPayload { payload.auto_sync_off = auto_sync_off; if payload.status == "synced" && !auto_sync_off { let first = payload.synced.first().map(|l| l.time_ms); payload.auto_offset_ms = compute_auto_offset(path, duration_secs, source_duration_secs, first).await; // Second evidence source, consulted only when the corroborated // duration/silence probe found nothing: the word-timing artifact. // When forced alignment has already measured this file against this // doc and the sung words consistently start N ms from their cues, // that median is the doc's shift. Cache read only — never computed // here. (Aligned WORD times themselves are absolute and exempt from // this offset; only line cues move. See ArtifactLine::ctc.) if payload.auto_offset_ms == 0 { if let Some(p) = path { // The exact shape the word-timing request sends: original // indexes, empties dropped — the artifact key depends on it. let lines: Vec = payload .synced .iter() .enumerate() .map(|(i, l)| crate::lyric_prep::PrepLine { index: i as u32, time_ms: l.time_ms as i64, text: l.text.clone(), }) .filter(|l| !l.text.trim().is_empty()) .collect(); if let Some(shift) = crate::lyric_prep::cue_shift_evidence(app, Path::new(p), &lines) { payload.auto_offset_ms = shift; } } } } payload } fn local_plain( sidecar_plain: &Option, embedded: &Option, ) -> Option<(String, &'static str)> { if let Some(text) = sidecar_plain { return Some((text.clone(), "sidecar")); } if let Some(embedded) = embedded { if let Some(text) = &embedded.plain { return Some((text.clone(), "embedded")); } } None } /// Candidate list for the picker: LRCLIB search results, synced-first. #[tauri::command] pub async fn lyrics_search( title: String, artist: String, ) -> Result, String> { let mut records = lrclib_search(&artist, &title).await?; records.sort_by_key(|r| { ( r.synced_lyrics.is_none(), r.plain_lyrics.is_none() && !r.instrumental, ) }); records.truncate(20); Ok(records .iter() .map(|r| { serde_json::json!({ "id": r.id, "title": r.track_name, "artist": r.artist_name, "album": r.album_name, "durationSecs": r.duration.max(0.0) as u32, "synced": r.synced_lyrics.is_some(), "instrumental": r.instrumental, }) }) .collect()) } /// Pin a picked candidate for this track — persisted as the user's choice. #[tauri::command] pub async fn lyrics_select( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, candidate_id: i64, ) -> Result { let record = lrclib_by_id(candidate_id).await?; let mut doc = record_to_doc(&record); doc.source = "picked".into(); let key = cache_key(artist.trim(), title.trim(), duration_secs); let payload = { let app = app.clone(); let doc2 = doc.clone(); tauri::async_runtime::spawn_blocking(move || { let mut entry = load_entry(&app, &key); entry.user = Some(doc2); entry.not_found_at = None; save_entry(&app, &key, &entry); entry.offset_ms }) .await .map_err(|e| e.to_string())? }; Ok(payload_from(&doc, payload, duration_secs)) } /// Persist the user's timing offset for this track. #[tauri::command] pub async fn lyrics_set_offset( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, offset_ms: i64, ) -> Result<(), String> { let key = cache_key(artist.trim(), title.trim(), duration_secs); tauri::async_runtime::spawn_blocking(move || { let mut entry = load_entry(&app, &key); entry.offset_ms = offset_ms.clamp(-30_000, 30_000); save_entry(&app, &key, &entry); }) .await .map_err(|e| e.to_string()) } /// Paste-your-own: timed text (LRC) becomes a synced user document, anything /// else plain. The user's document outranks every automatic source. #[tauri::command] pub async fn lyrics_save_text( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, text: String, ) -> Result { if text.len() > 256 * 1024 { return Err("Pasted lyrics are too large".into()); } // Treat the paste as LRC only when timestamps carry MOST of it — one // stray [0:30] inside otherwise-plain lyrics must not throw the rest away. let content_lines = text.lines().filter(|l| !l.trim().is_empty()).count().max(1); let lines = parse_lrc(&text); let timed_enough = !lines.is_empty() && lines.len() * 2 >= content_lines; let doc = if timed_enough { StoredDoc { source: "user".into(), synced: lines, plain: None, instrumental: false, source_duration_secs: None, } } else { let trimmed = text.trim().to_string(); if trimmed.is_empty() { return Err("Nothing to save".into()); } StoredDoc { source: "user".into(), synced: Vec::new(), plain: Some(trimmed), instrumental: false, source_duration_secs: None, } }; let key = cache_key(artist.trim(), title.trim(), duration_secs); let offset = { let app = app.clone(); let doc2 = doc.clone(); tauri::async_runtime::spawn_blocking(move || { let mut entry = load_entry(&app, &key); entry.user = Some(doc2); save_entry(&app, &key, &entry); entry.offset_ms }) .await .map_err(|e| e.to_string())? }; Ok(payload_from(&doc, offset, duration_secs)) } /// The vocal-band activity envelope for the sync editor's backdrop and the /// rough-timing generator. Coarse by design — see player::vocals. #[tauri::command] pub async fn lyrics_vocal_activity(path: String) -> Result { let activity = tauri::async_runtime::spawn_blocking(move || { player::vocals::vocal_activity(Path::new(&path), 100) }) .await .map_err(|e| e.to_string())??; Ok(serde_json::json!({ "frameMs": activity.frame_ms, "frames": activity.frames, })) } /// Persist hand-synced (or editor-corrected) lines as this track's own /// document — the top of the waterfall, like a paste. #[tauri::command] pub async fn lyrics_save_lines( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, lines: Vec, ) -> Result { let mut lines: Vec = lines .into_iter() .filter(|l| !l.text.trim().is_empty()) .collect(); lines.sort_by_key(|l| l.time_ms); if lines.is_empty() { return Err("Nothing to save".into()); } if lines.len() > 2_000 { return Err("Too many lines".into()); } let doc = StoredDoc { source: "user".into(), synced: lines, plain: None, instrumental: false, source_duration_secs: None, }; let key = cache_key(artist.trim(), title.trim(), duration_secs); let offset = { let app = app.clone(); let doc2 = doc.clone(); tauri::async_runtime::spawn_blocking(move || { let mut entry = load_entry(&app, &key); entry.user = Some(doc2); entry.not_found_at = None; save_entry(&app, &key, &entry); entry.offset_ms }) .await .map_err(|e| e.to_string())? }; Ok(payload_from(&doc, offset, duration_secs)) } /// Turn the automatic timing correction on or off for one track. #[tauri::command] pub async fn lyrics_set_auto_sync( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, enabled: bool, ) -> Result<(), String> { let key = cache_key(artist.trim(), title.trim(), duration_secs); tauri::async_runtime::spawn_blocking(move || { let mut entry = load_entry(&app, &key); entry.auto_sync_off = !enabled; save_entry(&app, &key, &entry); }) .await .map_err(|e| e.to_string()) } /// How many rendered instrumentals the cache keeps (they are WAVs — roughly /// 10 MB per minute — so the cap matters). const INSTRUMENTAL_CACHE_KEEP: usize = 10; /// Render (or fetch from cache) a vocal-reduced copy of a local file and /// return its path plus honest labels. Offline DSP — the classic /// band-limited center cancel, not a learned separator; the UI wording /// ("reduce vocals") must stay honest about that. #[tauri::command] pub async fn lyrics_prepare_instrumental( app: tauri::AppHandle, path: String, ) -> Result { use tauri::Manager as _; let source = PathBuf::from(&path); if !source.is_file() { return Err("The original file is not reachable right now".into()); } let dir = app .path() .app_cache_dir() .map_err(|e| e.to_string())? .join("instrumentals"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let stamp = std::fs::metadata(&source) .ok() .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); let key = { use std::hash::{Hash as _, Hasher as _}; let mut h = std::collections::hash_map::DefaultHasher::new(); "center-cancel-v1".hash(&mut h); path.hash(&mut h); stamp.hash(&mut h); format!("{:016x}", h.finish()) }; let dest = dir.join(format!("{key}.wav")); if dest.is_file() { // A cache hit refreshes the entry's touch file, so a WAV that keeps // being listened to never ages to the bottom of the LRU while ten // newer renders pile up above it. let _ = std::fs::write(dir.join(format!("{key}.touch")), b"1"); return Ok(serde_json::json!({ "path": dest.to_string_lossy(), "cached": true })); } let render_src = source.clone(); let render_dest = dest.clone(); let result = tauri::async_runtime::spawn_blocking(move || { // Stage beside the destination so a killed render never leaves a // half-written file answering to the cache key — and uniquely, so // two concurrent renders of the same track cannot collide mid-write. static STAGING_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let seq = STAGING_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let staging = render_dest.with_extension(format!("wav.rendering{seq}")); let rendered = match player::vocals::render_vocal_reduced(&render_src, &staging) { Ok(r) => r, Err(e) => { let _ = std::fs::remove_file(&staging); return Err(e); } }; std::fs::rename(&staging, &render_dest).map_err(|e| e.to_string())?; // Bounded cache: newest N stay by LAST USE (render or touch), not // render time alone. let parent = render_dest.parent().unwrap(); if let Ok(entries) = std::fs::read_dir(parent) { let mut files: Vec<(std::time::SystemTime, PathBuf)> = entries .flatten() .filter(|e| e.path().extension().is_some_and(|x| x == "wav")) .filter_map(|e| { let path = e.path(); let rendered_at = e.metadata().ok()?.modified().ok()?; let touched_at = std::fs::metadata(path.with_extension("touch")) .and_then(|m| m.modified()) .ok(); Some((touched_at.map_or(rendered_at, |t| t.max(rendered_at)), path)) }) .collect(); files.sort_by(|a, b| b.0.cmp(&a.0)); for (_, old) in files.into_iter().skip(INSTRUMENTAL_CACHE_KEEP) { let _ = std::fs::remove_file(old.with_extension("touch")); let _ = std::fs::remove_file(old); } } Ok::<_, String>(rendered) }) .await .map_err(|e| e.to_string())??; Ok(serde_json::json!({ "path": dest.to_string_lossy(), "cached": false, "durationSecs": result.duration_secs, })) } /// Forget everything stored for this track (user doc, network verdict, /// offset). The next lookup starts the waterfall from scratch. #[tauri::command] pub async fn lyrics_clear( app: tauri::AppHandle, title: String, artist: String, duration_secs: Option, ) -> Result<(), String> { let key = cache_key(artist.trim(), title.trim(), duration_secs); tauri::async_runtime::spawn_blocking(move || { if let Some(path) = cache_path(&app, &key) { let _ = std::fs::remove_file(path); } }) .await .map_err(|e| e.to_string()) } // ---- tests ----------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn lrc_basics_offsets_and_repeats() { let src = "\u{feff}[ar:Radiohead]\n[ti:Airbag]\n[offset:+500]\n[00:12.00]In the next world war\n[00:20.50][01:40.50]In a jackknifed juggernaut\n[00:30]I am born again\n\n[99:01.123]Way out\n"; let lines = parse_lrc(src); assert_eq!(lines.len(), 5); // offset +500 shifts everything 500ms earlier. assert_eq!(lines[0].time_ms, 11_500); assert_eq!(lines[0].text, "In the next world war"); assert_eq!(lines[1].time_ms, 20_000); assert_eq!(lines[2].time_ms, 29_500, "bare [mm:ss] works"); assert_eq!(lines[3].time_ms, 100_000, "repeated chorus timestamp"); assert_eq!(lines[4].time_ms, 99 * 60_000 + 1_000 + 123 - 500, "3-decimal + >59 minutes"); } #[test] fn enhanced_lrc_word_marks_become_word_timing() { let src = "[00:10.00]<00:10.00>Take <00:10.40>me <00:10.90>home"; let lines = parse_lrc(src); assert_eq!(lines[0].text, "Take me home"); let words = &lines[0].words; assert_eq!(words.len(), 3); assert_eq!(words[0].text, "Take"); assert_eq!(words[0].time_ms, 10_000); assert_eq!(words[2].text, "home"); assert_eq!(words[2].time_ms, 10_900); // A single stray mark is not word timing — display only. let one = parse_lrc("[00:10.00]Take <00:10.40>me home"); assert_eq!(one[0].text, "Take me home"); assert!(one[0].words.is_empty(), "partial coverage carries no word timing"); // Non-monotonic marks are refused. let bad = parse_lrc("[00:10.00]<00:11.00>Take <00:10.40>me <00:12.00>home"); assert!(bad[0].words.is_empty()); // A repeated-chorus line keeps words only on the occurrence the // absolute marks BELONG to — nearest stamp, not first-written. let dup = parse_lrc("[01:10.00][00:10.00]<00:10.00>La <00:10.40>la"); assert_eq!(dup.len(), 2); assert_eq!(dup[0].time_ms, 10_000, "sorted first"); assert_eq!(dup[0].words.len(), 2, "words ride the 10s occurrence"); assert!(dup[1].words.is_empty()); // Syllabic marks merge at whitespace boundaries, like the SYLT path. let syl = parse_lrc("[00:10.00]<00:10.00>Coun<00:10.20>try <00:10.50>roads"); assert_eq!(syl[0].text, "Country roads"); assert_eq!(syl[0].words.len(), 2); assert_eq!(syl[0].words[0].text, "Country"); assert_eq!(syl[0].words[1].text, "roads"); } #[test] fn sylt_units_group_into_lines_and_words() { // Word-level SYLT, newline convention: entries prefixed with \n // start a new display line. let entries = vec![ (1_000u32, "Take".to_string()), (1_400, " me".to_string()), (1_900, " home".to_string()), (5_000, "\nCountry".to_string()), (5_600, " roads".to_string()), ]; let lines = lines_from_sylt(&entries); assert_eq!(lines.len(), 2); assert_eq!(lines[0].text, "Take me home"); assert_eq!(lines[0].time_ms, 1_000); assert_eq!(lines[0].words.len(), 3); assert_eq!(lines[1].text, "Country roads"); assert_eq!(lines[1].words[1].time_ms, 5_600); // Syllabic content merges into words at whitespace boundaries. let syllables = vec![ (1_000u32, "Coun".to_string()), (1_200, "try".to_string()), (1_500, " roads".to_string()), ]; let lines = lines_from_sylt(&syllables); assert_eq!(lines.len(), 1); assert_eq!(lines[0].text, "Country roads"); assert_eq!(lines[0].words.len(), 2); assert_eq!(lines[0].words[0].text, "Country"); // Long entries with no newlines are already lines. let linelevel = vec![ (1_000u32, "Take me home, country roads".to_string()), (5_000, "To the place I belong".to_string()), ]; let lines = lines_from_sylt(&linelevel); assert_eq!(lines.len(), 2); assert!(lines[0].words.is_empty()); } #[test] fn metadata_only_is_not_synced() { assert!(parse_lrc("[ar:X]\n[ti:Y]\nJust text\n").is_empty()); assert!(parse_lrc("[00:10.00]\n[00:20.00]\n").is_empty(), "stamps with no words"); } #[test] fn yrc_words_credits_and_degradations() { // Latin lyrics: pieces carry their own trailing spaces. let src = "{\"t\":0,\"c\":[{\"tx\":\"credits line\"}]}\n[1000,3000](1000,400,0)Take (1400,500,0)me (1900,600,0)home\n[5000,2000](5000,300,0)Coun(5300,300,0)try (5600,400,0)roads\n"; let lines = parse_yrc(src); assert_eq!(lines.len(), 2, "the JSON credits line is skipped"); assert_eq!(lines[0].time_ms, 1_000); assert_eq!(lines[0].text, "Take me home"); assert_eq!(lines[0].words.len(), 3); assert_eq!(lines[0].words[2].text, "home"); assert_eq!(lines[0].words[2].time_ms, 1_900); // Syllable pieces with no whitespace between them merge, SYLT-style. assert_eq!(lines[1].text, "Country roads"); assert_eq!(lines[1].words.len(), 2); assert_eq!(lines[1].words[0].text, "Country"); assert_eq!(lines[1].words[0].time_ms, 5_000); // CJK: per-character pieces, no whitespace → one merged run → falls // below the two-word floor → line-level display, by design. let cjk = parse_yrc("[1000,2000](1000,200,0)你(1200,200,0)好(1400,200,0)世(1600,200,0)界\n"); assert_eq!(cjk[0].text, "你好世界"); assert!(cjk[0].words.is_empty()); // Non-monotonic word times are refused; the line survives. let bad = parse_yrc("[1000,2000](2000,200,0)late (1000,200,0)early\n"); assert_eq!(bad[0].text, "late early"); assert!(bad[0].words.is_empty()); // A stray '(' inside the text keeps the words visible, drops word mode. let stray = parse_yrc("[1000,2000](1000,200,0)Oh (no\n"); assert_eq!(stray[0].text, "Oh (no"); assert!(stray[0].words.is_empty()); } #[test] fn amll_index_candidate_matching() { let by_isrc = |index: &str, isrc: &str| -> Option { amll_index_candidates(index, Some(isrc), "", "", false) .first() .map(|c| c.id.clone()) }; let index = concat!( "{\"id\":\"111\",\"metadata\":[[\"album\",[\"X\"]],[\"isrc\",[\"USUM71703861\"]]],\"rawLyricFile\":\"a.ttml\"}\n", "not json at all\n", "{\"id\":\"222\",\"metadata\":[[\"isrc\",[\"gb-ahs-16-00001\",\"USRC17607839\"]],[\"artists\",[\"Y\"]]]}\n", "{\"id\":\"333\",\"metadata\":[[\"musicName\",[\"USUM79999999\"]]]}\n", "{\"id\":\"444\",\"metadata\":[[\"musicName\",[\"Take Me Home\",\"帯我回家\"]],[\"artists\",[\"The Band\",\"Guest\"]],[\"appleMusicId\",[\"1122773680\"]]]}\n", "{\"id\":\"555\",\"metadata\":[[\"musicName\",[\"Take Me Home\"]],[\"artists\",[\"Somebody Else\"]]]}\n", ); assert_eq!(by_isrc(index, "USUM71703861"), Some("111".into())); // Dashes/case in either side never matter, and multi-value ISRC lists work. assert_eq!(by_isrc(index, "GBAHS1600001"), Some("222".into())); assert_eq!(by_isrc(index, "usrc-17607839"), Some("222".into())); // An ISRC-shaped string under a DIFFERENT key is not a match. assert_eq!(by_isrc(index, "USUM79999999"), None); assert_eq!(by_isrc(index, ""), None); // Name matching: normalized title+artist equality on multi-value // name lists, non-exact flag, appleMusicId carried for the duration // gate — and only when the caller can validate (duration known). let named = amll_index_candidates(index, None, "take me home", "the band", true); assert_eq!( named, vec![AmllCandidate { id: "444".into(), exact: false, apple_id: Some("1122773680".into()), }] ); assert!( amll_index_candidates(index, None, "take me home", "the band", false).is_empty(), "no name candidates without a validatable duration" ); assert!( amll_index_candidates(index, None, "take me home", "nobody", true).is_empty(), "artist must agree" ); // An exact ISRC hit preempts name candidates entirely. let both = amll_index_candidates(index, Some("USUM71703861"), "take me home", "the band", true); assert_eq!(both.len(), 1); assert_eq!(both[0].id, "111"); assert!(both[0].exact); } #[test] fn ttml_word_timing_syllables_and_background() { let src = r#"

Take me home

Country roads乡村路 (oh oh)

"#; let lines = parse_ttml(src); assert_eq!(lines.len(), 2); assert_eq!(lines[0].time_ms, 7_759); assert_eq!(lines[0].text, "Take me home"); assert_eq!(lines[0].words.len(), 3); assert_eq!(lines[0].words[1].text, "me"); assert_eq!(lines[0].words[1].time_ms, 8_147); // Adjacent spans with no whitespace between them are syllables of one // word: merged text, first syllable's time. assert_eq!(lines[1].text, "Country roads"); assert_eq!(lines[1].words.len(), 2); assert_eq!(lines[1].words[0].text, "Country"); assert_eq!(lines[1].words[0].time_ms, 12_000); // Auxiliary layers are dropped whole: backing vocals, and the // translation span that sits flush against the last word (kept, it // would glue on: "roads乡村路"). assert!(!lines[1].text.contains("oh"), "background vocals stay off the main line"); assert_eq!(lines[1].words[1].text, "roads", "translations stay off the main words"); } #[test] fn ttml_line_timing_clock_forms_and_refusals() { // Line-timed document: p text with no timed spans → plain synced // lines, and every clock form Apple/AMLL files actually write. let src = r#"

Take me home

Country roads

To the place

"#; let lines = parse_ttml(src); assert_eq!(lines.len(), 3); assert!(lines.iter().all(|l| l.words.is_empty()), "no word marks, no word timing"); assert_eq!(lines[0].time_ms, 7_759); assert_eq!(lines[1].time_ms, 75_500); assert_eq!(lines[2].time_ms, 90_250); assert_eq!(lines[2].text, "To the place"); // Not TTML, malformed XML, or DTD-carrying: refused, never guessed. assert!(parse_ttml("[00:10.00]Take me home").is_empty()); assert!(parse_ttml("

dangling").is_empty()); assert!(parse_ttml("

x

").is_empty()); // A word line where one token lacks timing degrades to line-level — // word mode renders `words`, so a partial set would drop text. let partial = r#"

Take me

"#; let lines = parse_ttml(partial); assert_eq!(lines[0].text, "Take me"); assert!(lines[0].words.is_empty(), "partial coverage carries no word timing"); } #[test] fn timing_plausibility() { let lines = vec![ LyricLine { time_ms: 10_000, text: "a".into(), words: Vec::new() }, LyricLine { time_ms: 500_000, text: "b".into(), words: Vec::new() }, LyricLine { time_ms: 600_000, text: "c".into(), words: Vec::new() }, ]; assert!(!plausible_timing(&lines, Some(120)), "mostly past the end of a 2min track"); assert!(plausible_timing(&lines, Some(700))); assert!(plausible_timing(&lines, None), "no duration, nothing to contradict"); } #[test] fn sidecars_prefer_timed_and_respect_language_suffixes() { let dir = std::env::temp_dir().join("ssplus-lyrics-sidecar"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let audio = dir.join("01 Airbag.flac"); std::fs::write(&audio, b"x").unwrap(); // Only a plain .txt: needs several lines to count as lyrics. std::fs::write(dir.join("01 Airbag.txt"), "one\ntwo\nthree\nfour\n").unwrap(); let (timed, plain) = sidecar_lyrics(audio.to_str().unwrap()); assert!(timed.is_none()); assert_eq!(plain.as_deref(), Some("one\ntwo\nthree\nfour")); // A language-suffixed LRC variant is found without the exact name. std::fs::write(dir.join("01 Airbag.en.lrc"), "[00:12.00]In the next world war\n").unwrap(); let (timed, _) = sidecar_lyrics(audio.to_str().unwrap()); assert_eq!(timed.as_ref().map(|l| l.len()), Some(1)); // The exact-basename LRC wins over the variant. std::fs::write(dir.join("01 Airbag.lrc"), "[00:01.00]a\n[00:02.00]b\n").unwrap(); let (timed, _) = sidecar_lyrics(audio.to_str().unwrap()); assert_eq!(timed.as_ref().map(|l| l.len()), Some(2)); // A .txt that does not look like lyrics (too few lines) is ignored. std::fs::write(dir.join("01 Airbag.txt"), "tracker rip notes").unwrap(); let (_, plain) = sidecar_lyrics(audio.to_str().unwrap()); assert!(plain.is_none()); } #[test] fn cache_keys_bucket_duration() { assert_eq!( cache_key("Radiohead", "Airbag", Some(287)), cache_key("radiohead", "AIRBAG", Some(284)), ); assert_ne!( cache_key("Radiohead", "Airbag", Some(287)), cache_key("Radiohead", "Airbag (Live)", Some(287)), ); } }