lyric-viewer
Fullscreen, beat-aware synced lyric overlay for Windows.
github.com/dhruvch1244/lyric-viewer ↗Physical separation, with one named exception
The Rust side owns SMTC (Windows media session) integration, every network/LLM call, caching, and job scheduling — none of it references the WebView2 renderer. Delete the whole UI layer and the Rust side still makes sense on its own; the renderer could be swapped for a different drawing surface without touching it. There's exactly one deliberate crossing of that boundary: a synchronous JS DSP fallback for local file playback, used only when native analysis isn't available yet. It's allowed to exist because it's narrow (one named code path, not a general escape hatch), documented at the point of use (why it's there, what keeps it from becoming the common case), and unmistakably an exception rather than a pattern to copy.
`src-tauri/` (Rust) owns the window, OS integration (SMTC, tray, wallpaper
mode, power-state watchers), all network/LLM calls, local-file decode, and
all cached state. `src/renderer/` (plain JS, no framework/bundler) is a
WebView2 page that **draws only** — it reacts to Tauri events
(`mood`, `genre`, `lyrics`, `attribution`, `beatmap`, `track`, `tick`, ...)
via `src/renderer/tauri-shim.js` and never originates a network call, cache
write, or CPU-heavy analysis pass itself, with one narrow exception: local
file playback has a synchronous JS DSP fallback in `analyze.js` for when
native analysis is unavailable (see the async-command note below — this
fallback path is also *slow*, so keeping native analysis correctly async is
what keeps it from ever being needed on the hot path).One scheduler, sized to what it actually contends for
The job engine (src-tauri/src/jobs/mod.rs) doesn't give every kind of background work the same treatment. I/O-bound work gets a dedicated concurrency lane sized for the actual fan-out — the resource being protected is external responsiveness, not CPU. CPU-bound analysis gets a worker pool sized to N-1 cores, leaving one core for the UI thread. A resource that can only safely support one concurrent user gets a lane of exactly 1. A dedup_key means two triggers requesting lyrics for the same track collapse into one job instead of two racing network calls. Cancellation is a CancelToken tree, so cancelling a parent (the user changed tracks) cancels every child job transitively. Priority (Now/Next/Idle) is resolved when a lane actually frees up, not frozen at submit time. Crash survival — a SQLite-backed journal — is layered on top as a separate concern, not baked into the scheduling logic itself.
let cpu_threads = std::thread::available_parallelism()
.map(|n| n.get().saturating_sub(1).max(1))
.unwrap_or(1);
let mut lanes = Vec::with_capacity(Lane::ALL.len());
for lane in Lane::ALL {
let (threads, below_normal, limit, name) = match lane {
// 6 concurrent network calls is enough to fan out to every
// lyric and artwork source at once without looking like abuse
// to any of them.
Lane::Io => (6, false, 6, "job-io"),
Lane::Cpu => (cpu_threads, true, cpu_threads, "job-cpu"),
Lane::Inference => (1, true, 1, "job-infer"),
};
lanes.push(spawn_lane(
Pool::new(threads, below_normal, name),
limit,
Arc::clone(®istry),
));
}fn submit(&self, job: Box<dyn Runnable>, priority: Priority) -> bool {
let key = job.dedup_key();
let track = job.track();
let cancel = CancelToken::default();
// Register before queueing. Doing it the other way round would let two
// submissions of the same key both pass the check and both queue.
{
let mut reg = self.registry.lock().unwrap_or_else(|e| e.into_inner());
if reg.inflight.contains_key(&key) {
return false;
}
reg.inflight.insert(key.clone(), cancel.clone());
if let Some(track) = &track {
reg.by_track.entry(track.clone()).or_default().insert(key.clone());
}
}
let lane = self.lanes[job.lane().index()].tx[priority.index()].clone();
if lane.send(Envelope { job, cancel }).is_err() {
release(&self.registry, &key, track.as_deref());
return false;
}
true
}Measure, don't guess
A perf harness (scripts/perf/) exists specifically because informal benchmarking wasn't trustworthy enough to make claims from. One comment in the codebase records that results "vary 3-4x run to run on this hardware" — the kind of measured, specific number that's expensive to reconstruct once lost, so it stayed in the code as a comment instead of getting cleaned up as noise.
Drives the real app over the Chrome DevTools Protocol — this project's
standing rule is **measure the real thing, never guess or extrapolate from
"observed" frame rate** (repeated identical runs vary 3-4x on this hardware).
```sh
npm run perf # steady-state scenario harness (dev build)
npm run perf:build-release # build an instrumented release binary (own CARGO_TARGET_DIR, never bundled)
npm run perf -- --build release # run the harness against it
npm run perf:startup # from-launch startup-burst measurement
```Standard library first, for the parts that actually qualify
track_key is a hand-rolled djb2 hash of lower(artist)|lower(title); the streak counter uses hand-rolled weekday math. Both pass the actual test for hand-rolling something instead of reaching for a dependency: the whole problem fits in a screen or two of code, and neither will ever need a patch for reasons outside this project's control (no security advisory, no spec update, no external edge case). A hand-rolled timezone-aware date library would fail that same test immediately — which is exactly why this project doesn't have one.
/// Filename-safe cache key for a track (djb2 hash of normalised artist+title).
pub(crate) fn track_key(artist: &str, title: &str) -> String {
let base = format!("{}|{}", artist.to_lowercase().trim(), title.to_lowercase().trim());
let mut hash: u64 = 5381;
for b in base.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(b as u64);
}
format!("{hash:016x}")
}fn current_streak(days: &HashSet<i64>, today: i64) -> i64 {
let mut cursor = if days.contains(&today) {
today
} else if days.contains(&(today - 1)) {
today - 1
} else {
return 0;
};
let mut streak = 0i64;
while days.contains(&cursor) {
streak += 1;
cursor -= 1;
}
streak
}Honest about drift, not just about the present
The project's own docs/JOB-ENGINE.md tracks status per phase rather than describing one static architecture — including which pieces shipped differently than planned (a JS file slated for deletion that's still there on purpose, a phase whose proposed fixes were rejected once the numbers came back). Rather than silently rewrite history to match current reality, drift gets named explicitly. A doc that quietly claims to be fully current when it isn't is worse than one that admits where it moved — the first one costs the next reader (human or AI) real time re-discovering the gap themselves.
**Status:** Phase 1 landed except the SQLite journal, which moved into Phase 3 and is done (§7.1, §7.7). Phase 2 landed except the local-folder `Idle` backfill, which moved to Phase 7 (§7.2). **Phase 3 is complete**... Deleting `whisper.js` was the one item dropped rather than done, deliberately: §7.10 explains why the WebView path stays as the vocal-isolation fallback. Phase 4 closed — profiled, and the fixes it proposed were rejected on the measurements (§7.9).