2332 lines
91 KiB
Rust
2332 lines
91 KiB
Rust
//! 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, ¤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<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)),
|
||
);
|
||
}
|
||
}
|