Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,13 @@ The percentage comes from [`useKaraokeWordFill`](../../src/hooks/useKaraokeWordF

Fallbacks, all landing on the plain discrete highlight: `prefers-reduced-motion`, a word with no forward-going `endMs` (the last word of a track keeps `-1`, and sloppy sources can stamp two words at the same millisecond), and the side panel, which deliberately keeps the cheap version — it's a far smaller surface (the mini-player takes the fill despite being smaller still: it is a dedicated reading surface, not a strip beside one). The clip reveals left-to-right, so right-to-left lyrics fill from the wrong edge; the word-level highlight already had that limitation, so it's tracked separately rather than half-fixed here.

**Estimated word timing (#716).** Line-synced lyrics carry no word stamps, so none of the above ran on them. With `profile_setting['lyrics.estimate_words']` on (Settings → Lyrics, default off), [`useTrackLyrics`](../../src/hooks/useTrackLyrics.ts) gives the **active line only** words from [`estimateLineWords`](../../src/lib/lyricsWordEstimate.ts), and every view then draws them as it would real ones. It is a display estimate and is treated as one: re-derived each time a line becomes active, never written to the cache, a sidecar or a tag, and a line that already has real word timing is left untouched. Better than dividing the line equally, which is the obvious version:

- **Weighted by syllables** — vowel groups for alphabetic scripts, one per block for Hangul, one per character for Han and kana.
- **Pauses after punctuation** — a comma is half a syllable of silence, a full stop nearly one, spent between the two words rather than held by either.
- **A capped span** — at most 550 ms a syllable, so a line followed by an instrumental break is not stretched across the whole gap; 300 ms a syllable when the end is unknown (the last line).
- **Chinese and Japanese step by phrase, not by character.** Every view puts a space between two words, so a per-character split would write spaces into the line.

**Romanization and translation (issue #584).** An Apple TTML document can carry two further readings of every line, tucked in `<head>` rather than beside the lines: `<translations><translation xml:lang="…"><text for="…">` and `<transliterations><transliteration xml:lang="…"><text for="…"><span begin="…" end="…">`. Each entry points back at its line through the line's `itunes:key`, and Apple returns both from a single localized request — asking for a translation language is what brings the transliteration with it.

Measured on a full document (54 lines): one entry per line for each reading, and the transliteration carries **one span per original span with identical `begin` / `end` bounds**. That is the fact the rendering rests on — a romanized word is driven by the clock of the word it reads out, so `activeWordIndex` addresses both rows and the progressive fill needs no second timing pass.
Expand Down
7 changes: 6 additions & 1 deletion docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ An id of the form `tag:<key>` shows a frame from the user's files. Keys are offe

## "Needs attention" inventory

[`commands/inventory.rs`](../../src-tauri/crates/app/src/commands/inventory.rs), surfaced as the library's last tab (#589). Twelve counted categories you click into — six "missing field" checks, one for formats we cannot write tags into, four album-level inconsistencies, and probable duplicates.
[`commands/inventory.rs`](../../src-tauri/crates/app/src/commands/inventory.rs), surfaced as the library's last tab (#589). Thirteen counted categories you click into — six "missing field" checks, one for formats we cannot write tags into, four album-level inconsistencies, probable duplicates, and artists to split.

**It is an entry point, not a report.** Every category is an extra `WHERE` on `browse::library_tracks_sql_where`, the same query the Tracks tab and the folder browser render, so clicking one loads those tracks into the library's own table — same columns, same sort, same context menu, same properties modal. Fixing a track from the inventory is therefore the ordinary edit flow, not a second one.

Expand All @@ -222,6 +222,11 @@ Five things the implementation has to get right:
- **"Gap in the numbering" measures holes inside the observed range**, `MAX - MIN + 1 > COUNT(DISTINCT)`. The obvious spelling, `MAX > COUNT(DISTINCT)`, assumes every disc starts at 1 and reports a box set's third disc numbered 20-22 as missing nineteen tracks. The cost is that a missing *first* or *last* track is invisible — numbering alone cannot see it — which needs `album.total_tracks` and is left out rather than half-implemented.
- **Probable duplicates chain, they do not compare pairs.** SQL groups by exact normalized title + credit list; [`waveflow_core::inventory::chain_by_duration`](../../src-tauri/crates/core/src/inventory.rs) then chains sorted durations in Rust. 180 s, 181.5 s and 183 s are one recording, but the outer pair is 3 s apart, so a pairwise tolerance splits them — and *which* two it splits into depends on iteration order, so the same library answers differently between runs. Chaining is transitive by construction, which is the right trade for a list a human reviews.

**Artists to split (#719) is the one category made of artists, not tracks.** A comma-joined credit (`Ice Spice, Central Cee`) is one phantom credited on many tracks, all needing the same single fix, so it is listed once per artist, with the names the split would produce and the [split](#multi-artist) inline — a second click confirms it, since it relinks every track. The list uses the split's own `split_fragments`, so it never offers what the split would not do. Two decisions, both measured:

- **Every comma name is listed**, not only those whose fragments already exist as artists. That stronger signal was the obvious one, and a real library ruled it out: 18 of its 39 comma names had no fragment in the library at all, and every one was a genuine duo. Fragments that do exist are marked (they are the rows the split reuses) and sort a name up — evidence, not a gate.
- **A comma is still only a hint** (`Tyler, The Creator`), so each row offers "don't split", stored per profile in `profile_setting['inventory.dismissed_phantoms']` by canonical name, so a rescan that recreates the row does not bring it back.

This is **not** the same question as [duplicate detection](#duplicate-detection) below, which hashes content and finds byte-identical copies. Probable duplicates are for the re-rips and re-encodes content hashing can never group; the two are complementary.

## Duplicate detection
Expand Down
4 changes: 3 additions & 1 deletion docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ The audio path lives in [`src-tauri/crates/app/src/audio/`](../../src-tauri/crat

Real-time FFT curves surfaced in the immersive Now Playing overlay. Implementation:

- Backend: [`audio/spectrum.rs`](../../src-tauri/crates/app/src/audio/spectrum.rs) runs on the decoder thread (NOT in the cpal callback — too constrained). Post-EQ samples go through `SpectrumAnalyzer::feed`, which mono-mixes, applies a Hann window, runs a 2048-pt real FFT via `realfft`, then buckets the magnitudes into 48 log-spaced bands (30 Hz → 16 kHz). 50% overlap between successive frames so the visual feels continuous. Throttled to ~30 Hz via a manual `Instant` clock.
- Backend: [`audio/spectrum.rs`](../../src-tauri/crates/app/src/audio/spectrum.rs) runs on the decoder thread (NOT in the cpal callback — too constrained). Post-EQ samples go through `SpectrumAnalyzer::feed`, which mono-mixes, applies a Hann window, runs a 4096-pt real FFT via `realfft`, then buckets the magnitudes into 48 log-spaced bands (30 Hz → 16 kHz). The window slides by a fixed 1024 samples (75% overlap), so the longer window does not slow the update. Throttled to ~30 Hz via a manual `Instant` clock.
- **Bass resolution (#715).** At 2048 points a bin is 21.5 Hz wide at 44.1 kHz, and the twelve bands between 30 and 144 Hz were fed by six distinct bins: they moved in identical pairs. At 4096 a bin is 10.8 Hz, and a band still narrower than a bin reads the spectrum at its centre frequency, interpolated between the two nearest bins, so no two bands report the same number.
- **Level (#715).** Bands are scaled against a running reference that follows the loudest band — up in ~250 ms, down over ~3 s, with a floor so silence is never amplified into noise — plus 15% headroom. It replaced a fixed reference of 250, which a quietly mastered track never approached. The goal is the right movement, not more of it: no smoothing is added here, the React side keeps its own attack / release, and the reference resets on track change.
- Output is a `player:spectrum` Tauri event carrying a `Vec<f32>` of normalised band magnitudes (0..1, peaks may briefly overshoot).
- A `SharedPlayback::visualizer_enabled` atomic gates the entire path: when off, `feed` returns at the first atomic load — zero allocations, zero FFT cost. Persisted in `profile_setting['ui.visualizer']`, default OFF.
- Frontend: [`SpectrumVisualizer`](../../src/components/player/SpectrumVisualizer.tsx) subscribes to the event and drives a `<canvas>` with `requestAnimationFrame`. Asymmetric decay (jump up fast, fall slow) so transients pop without looking glitchy. Auto-fades to zero on pause so the drawing doesn't freeze mid-pose — a curve settles onto a flat line.
Expand Down
1 change: 1 addition & 0 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ The request was for the appearance choice Chrome offers on Linux, and it does no
- **Persistent bounds** — position + size are persisted in `app_setting['mini_player.bounds']` (JSON blob, machine-level) via debounced `onMoved` / `onResized` listeners in [`MiniPlayer.tsx`](../../src/components/views/MiniPlayer.tsx) (300 ms after the last gesture so SQLite isn't hammered at 60 Hz while dragging). On open, [`miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts) restores the saved rectangle when it still overlaps an available monitor by at least 80 px on both axes (`availableMonitors()` check guards against monitor disconnects / resolution changes). Otherwise it falls back to anchoring bottom-right of the primary monitor (`currentMonitor` → physical size ÷ scale factor → logical px) with a 24 px edge margin so the OS taskbar / Dock isn't covered.
- **Routing** — same Vite bundle, branched in [`main.tsx`](../../src/main.tsx) on `?mini=1` so the mini boots into a stripped-down provider tree (`Theme + Profile + Player` only — no `Library` / `Playlist` since the widget never browses).
- **Cover-derived background** — [`lib/dominantColor.ts`](../../src/lib/dominantColor.ts) draws the artwork onto a 64×64 canvas, samples every 4th pixel, skips near-monochrome runs (white margins, black bars) so the average reflects the real hue, and produces a 3-stop gradient applied to the window background.
- **Canvas and motion cover (#717)** — the same chain as the panel and the immersive view, Canvas > motion cover > slideshow > still cover, with the same gates (the Show Canvas toggle, `prefers-reduced-motion`). The clip is cropped into the square slot rather than given a tall frame, which would push the controls out of a 280-pixel window. It is a second video decode while the main window may be showing the same clip. The toggle itself lives in `localStorage`, which the two webviews share but whose in-memory copy they do not, so [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) listens for `storage` events: a toggle in the main window reaches an open mini-player without reopening it.
- **Hover overlay controls** — shuffle / prev / play (white round Spotify-style) / next / repeat fade in over the cover, with a compact **volume slider + mute** on a second row (#511); idle state shows just the artwork. The overlay also reveals on `focus-within` — its controls stay in the tab order, and a keyboard user shouldn't be driving a slider they can't see.
- **Shared state is broadcast, not per-window** — one engine behind two `PlayerContext`s, so anything reachable from both windows travels as an event: `player:volume-changed` from [`player_set_volume`](../../src-tauri/crates/app/src/commands/player.rs), `player:options-changed` from the shuffle / repeat commands, and `track:liked-changed` from [`toggle_like_track`](../../src-tauri/crates/app/src/commands/track.rs) (#523). The heart is shared through [`useLikedTracks`](../../src/hooks/useLikedTracks.ts), which the player bar and the mini-player both use rather than each keeping their own set. Only volume needs an echo guard — see [`invariants.md`](../architecture/invariants.md#events).
- **Drag region** — `data-tauri-drag-region` on the central dot strip, plus an explicit `getCurrentWindow().startDragging()` `onMouseDown` as a belt-and-suspenders fallback for the Windows hit-test races. Requires `core:window:allow-start-dragging` in the capability (not in `core:default`).
Expand Down
Loading
Loading