feat(slint): lyrics sync engine with karaoke fill and auto-center scroll
S4 of the lyrics epic: ms-precision position source, 30Hz sync engine, word-anchored karaoke fill, auto-centered ladder scroll, QConnect peer following. - qbz-player: read-only SharedState::current_position_ms() derived from the existing playback_start_millis/position_at_start anchors (Q1; no stream/seek/format path touched) + unit test - qbz-lyrics: new sync module porting the Tauri active-line math (binary search active index, per-line progress with the 0.99 snap) plus the word-anchored clip fraction for Qobuz wsync lines (char-proportional word width approximation, Q2); unit tests - qbz-slint: lyrics_sync engine - adaptive slint::Timer (33ms while panel open + synced + playing, 250ms gate polling otherwise), equality-guarded pushes, fill animation zeroed across discontinuities (line change / backward seek); kick() on doc commit and panel open - QConnect (Q7): the poll loop controller branch publishes the raw renderer anchor; the engine extrapolates it per tick so lyrics follow the peer (Tauri freezes); local fallthrough clears the anchor - LyricsLinesView: karaoke fill via clipped bright overlay text over the dim base (rect-clip approximation, D5); auto-center scroll from layout-relative active-row geometry, smooth for <=2-line moves and instant for jumps/initial sync, animated via a separate scroll-target property so user wheel input is never animated - LyricsState: line-progress + fill-anim-ms properties cc
This commit is contained in:
9 files changed
+761
-18
No files matched your search
@@ -23,6 +23,9 @@
|
||||
//! cache probe (plain-only = soft miss) -> Qobuz primary -> LRCLIB ->
|
||||
//! lyrics.ovh, offline cache-only mode, in-flight dedupe (F6), stale-guard
|
||||
//! echo (F2 support).
|
||||
//! - [`sync`] — sync-engine pure functions (spec §4.2): active-line binary
|
||||
//! search, per-line progress with the 0.99 snap, the word-anchored karaoke
|
||||
//! clip fraction (Q2).
|
||||
//!
|
||||
//! No frontend types anywhere below this line — the Slint/Tauri glue is a
|
||||
//! thin adapter over [`service::LyricsService`].
|
||||
@@ -32,6 +35,7 @@ pub mod lrc;
|
||||
pub mod model;
|
||||
pub mod providers;
|
||||
pub mod service;
|
||||
pub mod sync;
|
||||
pub mod wsync;
|
||||
|
||||
pub use cache::{CachedLyrics, LyricsCacheDb, LyricsCacheStats};
|
||||
@@ -42,4 +46,5 @@ pub use service::{
|
||||
HttpLyricsProviders, LyricsOutcome, LyricsProviders, LyricsRequest, LyricsResponse,
|
||||
LyricsResult, LyricsService, LyricsSourceKind,
|
||||
};
|
||||
pub use sync::{find_active_line_index, line_fill_fraction, line_progress};
|
||||
pub use wsync::{QobuzWsync, QobuzWsyncLine, QobuzWsyncWord};
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Sync-engine pure functions — the Rust port of the Tauri store's
|
||||
//! active-line math (`lyricsStore.ts:221-264`), spec §4.2:
|
||||
//!
|
||||
//! - [`find_active_line_index`]: binary search over `time_ms` for the last
|
||||
//! line at-or-before the playback position; `-1` before the first stamp
|
||||
//! (`findActiveLineIndex`, `lyricsStore.ts:221-239`).
|
||||
//! - [`line_progress`]: per-line progress 0→1 with the Tauri bound chain
|
||||
//! `end_ms ?? next.time_ms ?? time_ms + 5000`, clamped, **snapped to 1.0
|
||||
//! at ratio >= 0.99** (the anti-stuck-tail guard, `lyricsStore.ts:261`).
|
||||
//! - [`line_fill_fraction`]: the karaoke clip fraction (Q2 final form) —
|
||||
//! **word-anchored** when the line carries native Qobuz wsync words,
|
||||
//! line-proportional (== `line_progress`) for LRC-sourced lines.
|
||||
//!
|
||||
//! All functions are pure over [`LyricsLine`] slices: headless-testable,
|
||||
//! frontend-agnostic (ADR-006), shared by any future surface (miniplayer,
|
||||
//! ImmersiveView, Tauri adoption).
|
||||
|
||||
use crate::model::LyricsLine;
|
||||
|
||||
/// Fallback duration for a line with no `end_ms` and no successor
|
||||
/// (Tauri `calculateLineProgress`, `lyricsStore.ts:244-264`).
|
||||
pub const DEFAULT_LINE_DURATION_MS: i64 = 5_000;
|
||||
|
||||
/// Progress ratio at/above which the value snaps to exactly 1.0 — guards the
|
||||
/// visually stuck "0.2% tail" (`lyricsStore.ts:261`).
|
||||
pub const PROGRESS_SNAP: f32 = 0.99;
|
||||
|
||||
fn snap(ratio: f32) -> f32 {
|
||||
if ratio >= PROGRESS_SNAP {
|
||||
1.0
|
||||
} else {
|
||||
ratio
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the active line at `now_ms`: the LAST line whose `time_ms` is
|
||||
/// `<= now_ms`; `-1` before the first stamp or for unstamped (plain) docs.
|
||||
/// Binary search — `lines` must be stamp-ordered (parser/wsync guarantee);
|
||||
/// missing stamps are treated as unreachable.
|
||||
pub fn find_active_line_index(lines: &[LyricsLine], now_ms: i64) -> i32 {
|
||||
if lines.is_empty() {
|
||||
return -1;
|
||||
}
|
||||
let stamp = |i: usize| lines[i].time_ms.unwrap_or(i64::MAX);
|
||||
if now_ms < stamp(0) {
|
||||
return -1;
|
||||
}
|
||||
let mut lo = 0usize;
|
||||
let mut hi = lines.len();
|
||||
// Invariant: stamp(lo) <= now_ms < stamp(hi) (hi exclusive).
|
||||
while lo + 1 < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if stamp(mid) <= now_ms {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo as i32
|
||||
}
|
||||
|
||||
/// Time-proportional progress 0..=1 of `lines[index]` at `now_ms`.
|
||||
/// Bound chain + snap per the Tauri reference (see module docs).
|
||||
pub fn line_progress(lines: &[LyricsLine], index: usize, now_ms: i64) -> f32 {
|
||||
let Some(line) = lines.get(index) else {
|
||||
return 0.0;
|
||||
};
|
||||
let Some(start) = line.time_ms else {
|
||||
return 0.0;
|
||||
};
|
||||
let bound = line
|
||||
.end_ms
|
||||
.or_else(|| lines.get(index + 1).and_then(|next| next.time_ms))
|
||||
.unwrap_or(start + DEFAULT_LINE_DURATION_MS);
|
||||
if bound <= start {
|
||||
return 1.0;
|
||||
}
|
||||
let ratio = (now_ms - start) as f32 / (bound - start) as f32;
|
||||
snap(ratio.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Karaoke clip fraction 0..=1 of the line's rendered width at `now_ms`
|
||||
/// (drives the rect-clip overlay width — Q2 final form).
|
||||
///
|
||||
/// - **wsync path** (line has native `words`): the fraction interpolates
|
||||
/// between REAL word boundaries. **Width approximation (documented):** a
|
||||
/// word's pixel width is approximated by its character count's share of
|
||||
/// the line's total characters — the engine has no glyph metrics, and the
|
||||
/// per-char proportion is within a few percent for typical lyric text.
|
||||
/// Each word except the last also absorbs one separator character, so the
|
||||
/// fill sweeps the inter-word space while/after that word is sung; in the
|
||||
/// silent gap between two words the fill holds at the end of the sung
|
||||
/// word's span.
|
||||
/// - **LRC path** (no words): degrades to the line-proportional
|
||||
/// [`line_progress`] — exactly Tauri's behavior for external providers.
|
||||
///
|
||||
/// The uniform >=0.99 snap applies to the final fraction in both paths.
|
||||
pub fn line_fill_fraction(lines: &[LyricsLine], index: usize, now_ms: i64) -> f32 {
|
||||
let Some(line) = lines.get(index) else {
|
||||
return 0.0;
|
||||
};
|
||||
let Some(words) = line.words.as_ref().filter(|words| !words.is_empty()) else {
|
||||
return line_progress(lines, index, now_ms);
|
||||
};
|
||||
|
||||
// Char-proportional weights: word chars + 1 separator for all but last.
|
||||
let weights: Vec<f32> = words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, word)| {
|
||||
let chars = word.text.chars().count() as f32;
|
||||
if i + 1 < words.len() {
|
||||
chars + 1.0
|
||||
} else {
|
||||
chars
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let total: f32 = weights.iter().sum();
|
||||
if total <= 0.0 {
|
||||
return line_progress(lines, index, now_ms);
|
||||
}
|
||||
|
||||
if now_ms < words[0].start {
|
||||
return 0.0;
|
||||
}
|
||||
if now_ms >= words[words.len() - 1].end {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Current word = last word whose start <= now (words are stamp-ordered).
|
||||
let mut current = 0usize;
|
||||
for (i, word) in words.iter().enumerate() {
|
||||
if word.start <= now_ms {
|
||||
current = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let word = &words[current];
|
||||
let span = word.end.saturating_sub(word.start);
|
||||
let word_progress = if span > 0 {
|
||||
((now_ms - word.start) as f32 / span as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let filled: f32 =
|
||||
weights[..current].iter().sum::<f32>() + weights[current] * word_progress;
|
||||
snap((filled / total).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Word;
|
||||
|
||||
fn synced_line(time_ms: i64, end_ms: Option<i64>, text: &str) -> LyricsLine {
|
||||
LyricsLine {
|
||||
time_ms: Some(time_ms),
|
||||
end_ms,
|
||||
text: text.into(),
|
||||
words: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ladder() -> Vec<LyricsLine> {
|
||||
vec![
|
||||
synced_line(1_000, Some(2_500), "one"),
|
||||
synced_line(3_000, None, "two"),
|
||||
synced_line(7_000, None, "three"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_index_binary_search_semantics() {
|
||||
let lines = ladder();
|
||||
assert_eq!(find_active_line_index(&[], 5_000), -1);
|
||||
assert_eq!(find_active_line_index(&lines, 0), -1); // before first stamp
|
||||
assert_eq!(find_active_line_index(&lines, 1_000), 0); // exactly at stamp
|
||||
assert_eq!(find_active_line_index(&lines, 2_999), 0);
|
||||
assert_eq!(find_active_line_index(&lines, 3_000), 1);
|
||||
assert_eq!(find_active_line_index(&lines, 999_999), 2); // after last
|
||||
|
||||
// Plain (unstamped) docs never activate.
|
||||
let plain = vec![LyricsLine {
|
||||
time_ms: None,
|
||||
end_ms: None,
|
||||
text: "p".into(),
|
||||
words: None,
|
||||
}];
|
||||
assert_eq!(find_active_line_index(&plain, 5_000), -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_bound_chain_and_snap() {
|
||||
let lines = ladder();
|
||||
// end_ms bound: 1000..2500 → midpoint.
|
||||
assert!((line_progress(&lines, 0, 1_750) - 0.5).abs() < 1e-4);
|
||||
// No end_ms → next line's stamp: 3000..7000.
|
||||
assert!((line_progress(&lines, 1, 5_000) - 0.5).abs() < 1e-4);
|
||||
// Last line, no end_ms, no next → +5000 default: 7000..12000.
|
||||
assert!((line_progress(&lines, 2, 9_500) - 0.5).abs() < 1e-4);
|
||||
// Clamped to [0, 1].
|
||||
assert_eq!(line_progress(&lines, 0, 0), 0.0);
|
||||
assert_eq!(line_progress(&lines, 0, 99_999), 1.0);
|
||||
// Snap: >= 0.99 becomes exactly 1.0 (1000 + 0.995*1500 = 2492.5).
|
||||
assert_eq!(line_progress(&lines, 0, 2_493), 1.0);
|
||||
// Out-of-range index is inert.
|
||||
assert_eq!(line_progress(&lines, 9, 1_000), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_fraction_lrc_path_equals_line_progress() {
|
||||
let lines = ladder();
|
||||
assert_eq!(
|
||||
line_fill_fraction(&lines, 0, 1_750),
|
||||
line_progress(&lines, 0, 1_750)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_fraction_word_anchored() {
|
||||
// "ab cd": weights ab=2+1(sep)=3, cd=2; total 5.
|
||||
let line = LyricsLine {
|
||||
time_ms: Some(1_000),
|
||||
end_ms: Some(5_000),
|
||||
text: "ab cd".into(),
|
||||
words: Some(vec![
|
||||
Word {
|
||||
start: 1_000,
|
||||
end: 2_000,
|
||||
text: "ab".into(),
|
||||
},
|
||||
Word {
|
||||
start: 3_000,
|
||||
end: 5_000,
|
||||
text: "cd".into(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
let lines = vec![line];
|
||||
|
||||
// Before the first word.
|
||||
assert_eq!(line_fill_fraction(&lines, 0, 500), 0.0);
|
||||
// Halfway through word 1: 3 * 0.5 / 5 = 0.3.
|
||||
assert!((line_fill_fraction(&lines, 0, 1_500) - 0.3).abs() < 1e-4);
|
||||
// In the gap between words: holds at word 1's full span, 3/5 = 0.6.
|
||||
assert!((line_fill_fraction(&lines, 0, 2_500) - 0.6).abs() < 1e-4);
|
||||
// Halfway through word 2: (3 + 2*0.5) / 5 = 0.8.
|
||||
assert!((line_fill_fraction(&lines, 0, 4_000) - 0.8).abs() < 1e-4);
|
||||
// At/after the last word's end.
|
||||
assert_eq!(line_fill_fraction(&lines, 0, 5_000), 1.0);
|
||||
// Snap near the end: (3 + 2*0.99)/5 = 0.996 >= 0.99 → 1.0.
|
||||
assert_eq!(line_fill_fraction(&lines, 0, 4_980), 1.0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user