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

2332 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 `<library>/.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<LyricWord>,
}
#[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<LyricLine>,
pub plain: Option<String>,
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<LyricLine>,
#[serde(default)]
plain: Option<String>,
#[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<f64>,
}
/// 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<StoredDoc>,
#[serde(default)]
lrclib: Option<StoredDoc>,
/// 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<StoredDoc>,
#[serde(default)]
word_not_found_at: Option<i64>,
#[serde(default)]
not_found_at: Option<i64>,
#[serde(default)]
auto_sync_off: bool,
}
// ---- cache -----------------------------------------------------------------
fn lyrics_dir(app: &tauri::AppHandle) -> Option<PathBuf> {
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<u32>) -> 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<PathBuf> {
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 (`<mm:ss.xx>`), which are flattened to line text.
/// Returns lines sorted by time; empty when nothing timed was found.
pub fn parse_lrc(text: &str) -> Vec<LyricLine> {
let text = text.trim_start_matches('\u{feff}');
let mut offset_ms: i64 = 0;
let mut lines: Vec<LyricLine> = 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<i64> = 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<i64> {
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::<i64>().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<LyricWord>) {
// Pass 1: split into (mark, following-raw-text) chunks.
let mut chunks: Vec<(Option<i64>, 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 "Ka<t>ra<t>o<t>ke" renders "Karaoke".
let mut words: Vec<LyricWord> = 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<LyricLine> {
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::<usize>()
/ 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<LyricLine> = Vec::new();
let mut words: Vec<LyricWord> = Vec::new();
let mut previous_raw = String::new();
let flush_line = |words: &mut Vec<LyricWord>, lines: &mut Vec<LyricLine>| {
if words.is_empty() {
return;
}
let text = words
.iter()
.map(|w| w.text.as_str())
.collect::<Vec<_>>()
.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. `<p begin=…>` is a line; `<span begin=…>` 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
/// (`<p>` 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<LyricLine> {
use quick_xml::events::Event;
let text = text.trim_start_matches('\u{feff}');
if !text.contains("<tt") {
return Vec::new();
}
if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
// Same stance as the XSPF importer: quick-xml never resolves these,
// and a lyric file carrying them is not worth guessing about.
return Vec::new();
}
let mut reader = quick_xml::Reader::from_str(text);
// Deliberately NOT trim_text: the whitespace BETWEEN word spans is the
// word boundary.
/// One display token — a merged syllable run. `time` is the first timed
/// syllable's begin; None when nothing under it carried one.
struct Token {
time: Option<i64>,
text: String,
}
let mut lines: Vec<LyricLine> = Vec::new();
let mut in_body = false;
let mut in_p = false;
let mut p_begin: Option<i64> = None;
let mut tokens: Vec<Token> = Vec::new();
let mut boundary = true; // the next visible text starts a new token
// Open <span> begins, innermost last — text takes the nearest timed one.
let mut span_stack: Vec<Option<i64>> = 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 </span> 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::<Vec<_>>()
.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<i64> = 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) => {
// <br/> 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<String> {
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<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let scaled = |v: &str, scale: f64| -> Option<i64> {
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<i64> {
(!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<u32>) -> 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<Vec<LyricLine>>, Option<String>) {
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<String>,
#[serde(default)]
pub synced_lyrics: Option<String>,
}
fn http_client() -> Result<reqwest::Client, String> {
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<u32>,
) -> Result<Option<LrclibRecord>, 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<Vec<LrclibRecord>, 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<LrclibRecord, String> {
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<u32>,
) -> 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 `<body dur>` 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<String, String> {
let cache = lyrics_dir(app).map(|d| d.join("amll-index").join(format!("{platform}.jsonl")));
let read_cache = |path: PathBuf, max_age: Option<u64>| 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<String>,
}
/// 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<AmllCandidate> {
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<AmllCandidate> = 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::<serde_json::Value>(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<Option<f64>, 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<u32>,
) -> Result<Option<StoredDoc>, 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<LyricLine> {
let mut lines: Vec<LyricLine> = 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<i64> = 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<LyricWord> = 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<i64> = 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::<Vec<_>>().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::<Vec<_>>()
.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<u32>,
source_duration_secs: Option<f64>,
first_line_ms: Option<u32>,
) -> 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<u32>) -> 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<String>,
duration_secs: Option<u32>,
path: Option<String>,
refresh: Option<bool>,
) -> Result<LyricsPayload, String> {
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, &current);
})
.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, &current);
})
.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<u32>,
source_duration_secs: Option<f64>,
) -> 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<crate::lyric_prep::PrepLine> = 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<String>,
embedded: &Option<tagger::EmbeddedLyrics>,
) -> 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<Vec<serde_json::Value>, 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<u32>,
candidate_id: i64,
) -> Result<LyricsPayload, String> {
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<u32>,
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<u32>,
text: String,
) -> Result<LyricsPayload, String> {
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<serde_json::Value, String> {
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<u32>,
lines: Vec<LyricLine>,
) -> Result<LyricsPayload, String> {
let mut lines: Vec<LyricLine> = 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<u32>,
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<serde_json::Value, String> {
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<u32>,
) -> 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<String> {
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#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" itunes:timing="Word">
<head><metadata><ttm:agent type="person" xml:id="v1"/></metadata></head>
<body dur="3:20.000"><div begin="7.759" end="24.821" itunes:songPart="Verse">
<p begin="7.759" end="11.468" itunes:key="L1" ttm:agent="v1"><span begin="7.759" end="8.147">Take</span> <span begin="8.147" end="8.508">me</span> <span begin="8.508" end="9.001">home</span></p>
<p begin="12.000" end="15.000"><span begin="12.000">Coun</span><span begin="12.300">try</span> <span begin="12.800">roads</span><span ttm:role="x-translation" xml:lang="zh-CN">乡村路</span> <span ttm:role="x-bg"><span begin="13.000" end="13.400">(oh</span> <span begin="13.400" end="13.900">oh)</span></span></p>
</div></body></tt>"#;
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#"<tt xmlns="http://www.w3.org/ns/ttml"><body>
<p begin="0:07.759" end="0:11.468">Take me home</p>
<p begin="00:01:15.500" end="00:01:20.000">Country roads</p>
<p begin="90.25s" end="95s">To the place</p>
</body></tt>"#;
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("<tt><body><p begin=\"1.0\">dangling").is_empty());
assert!(parse_ttml("<!DOCTYPE tt><tt><body><p begin=\"1.0\">x</p></body></tt>").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#"<tt><body><p begin="5.0"><span begin="5.0">Take</span> me</p></body></tt>"#;
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)),
);
}
}