diff --git a/docs/features/integrations.md b/docs/features/integrations.md index b77ecc4a..f80de7fa 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -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 `` rather than beside the lines: `` and ``. 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. diff --git a/docs/features/library.md b/docs/features/library.md index 706b974c..67faadb6 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -210,7 +210,7 @@ An id of the form `tag:` 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. @@ -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 diff --git a/docs/features/playback.md b/docs/features/playback.md index 7afdb129..cf701b93 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -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` 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 `` 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. diff --git a/docs/features/ui.md b/docs/features/ui.md index ff6c29a8..438d4295 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -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`). diff --git a/src-tauri/crates/app/src/audio/spectrum.rs b/src-tauri/crates/app/src/audio/spectrum.rs index 72f658b2..6f3f98bc 100644 --- a/src-tauri/crates/app/src/audio/spectrum.rs +++ b/src-tauri/crates/app/src/audio/spectrum.rs @@ -27,11 +27,33 @@ use tauri::{AppHandle, Emitter}; use super::state::SharedPlayback; -/// Number of frames analysed per FFT pass. 2048 @ 44.1 kHz gives -/// ~46 ms of context per frame — enough resolution to show the -/// fundamental of bass notes (sub-25 Hz bin spacing) without smearing -/// transients on attacks. -const FFT_SIZE: usize = 2048; +/// Number of frames analysed per FFT pass. 4096 @ 44.1 kHz is a +/// 10.8 Hz bin — at 2048 (21.5 Hz) the twelve bass bands between 30 and +/// 144 Hz were fed by six distinct bins, so they moved in identical +/// pairs (#715). The window is ~93 ms long, but it slides by [`HOP`], so +/// a new frame is still ready every ~23 ms. +const FFT_SIZE: usize = 4096; + +/// How far the window slides between two frames. Fixed rather than +/// `FFT_SIZE / 2`: doubling the window must not halve the update rate. +const HOP: usize = 1024; + +/// How fast the level reference follows the music, up and down. It rises +/// within a fraction of a second so a loud passage does not pin every bar +/// to the top, and sinks over seconds so a quiet bar in a loud song stays +/// quiet instead of being inflated between two beats. +const LEVEL_ATTACK: Duration = Duration::from_millis(250); +const LEVEL_RELEASE: Duration = Duration::from_secs(3); + +/// The level reference never drops below this magnitude. Without a floor +/// a fade-out or a silent gap would be scaled up until its noise filled +/// the display; with it, a quietly mastered track gets about 6x the gain +/// the old fixed reference gave it, and no more. +const MIN_LEVEL: f32 = 40.0; + +/// Room above the reference: the bars reach the top on the peaks of the +/// music, not on its average. +const HEADROOM: f32 = 1.15; /// Number of output bands (log-spaced bars sent to the UI). pub const BAND_COUNT: usize = 48; @@ -66,6 +88,10 @@ pub struct SpectrumAnalyzer { scratch: Vec>, /// Output band magnitudes (length = BAND_COUNT). bands: Vec, + /// Raw (unnormalised) band magnitudes of the current frame. + raw: Vec, + /// Running level reference the bands are scaled against (#715). + level: f32, last_emit: Instant, } @@ -84,6 +110,8 @@ impl SpectrumAnalyzer { window, scratch, bands: vec![0.0; BAND_COUNT], + raw: vec![0.0; BAND_COUNT], + level: MIN_LEVEL, // Start "long ago" so the first window emits immediately. last_emit: Instant::now() .checked_sub(EMIT_INTERVAL) @@ -96,6 +124,10 @@ impl SpectrumAnalyzer { /// frame of the new track. pub fn reset(&mut self) { self.pending.clear(); + // A new track starts from the floor, not from the last one's + // loudness: a quiet track after a loud one would otherwise stay + // dim for seconds. + self.level = MIN_LEVEL; } /// Feed interleaved samples produced by the decoder. No-ops fast @@ -115,6 +147,9 @@ impl SpectrumAnalyzer { if !self.pending.is_empty() { self.pending.clear(); } + // Same reason for the level: re-enabled mid-track, the old + // reference would dim the bars for seconds while it decays. + self.level = MIN_LEVEL; return; } if channels == 0 || sample_rate <= 0.0 { @@ -141,26 +176,28 @@ impl SpectrumAnalyzer { let now = Instant::now(); let due = now.duration_since(self.last_emit) >= EMIT_INTERVAL; if due { - self.run_one_frame(sample_rate); + let elapsed = now + .duration_since(self.last_emit) + .min(Duration::from_secs(1)); + self.run_one_frame(sample_rate, elapsed); let payload = SpectrumPayload { bands: self.bands.clone(), }; let _ = app.emit(EVENT_NAME, payload); self.last_emit = now; } - // Slide the window: drop FFT_SIZE / 2 so the next pass - // shares half its data with the previous one. Keeps the - // visualizer feeling continuous rather than strobing. - let drop = FFT_SIZE / 2; - if self.pending.len() > drop { - self.pending.drain(..drop); + // Slide the window by HOP, so consecutive passes overlap by + // three quarters. Keeps the visualizer feeling continuous + // rather than strobing. + if self.pending.len() > HOP { + self.pending.drain(..HOP); } else { self.pending.clear(); } } } - fn run_one_frame(&mut self, sample_rate: f32) { + fn run_one_frame(&mut self, sample_rate: f32, elapsed: Duration) { // Apply the Hann window into the FFT input buffer. for (i, dst) in self.input.iter_mut().enumerate() { *dst = self.pending[i] * self.window[i]; @@ -177,7 +214,10 @@ impl SpectrumAnalyzer { return; } - compute_bands(&self.spectrum, sample_rate, &mut self.bands); + band_magnitudes(&self.spectrum, sample_rate, &mut self.raw); + let frame_peak = self.raw.iter().copied().fold(0.0f32, f32::max); + self.level = follow_level(self.level, frame_peak, elapsed); + scale_bands(&self.raw, self.level, &mut self.bands); } } @@ -194,63 +234,87 @@ fn hann_window(n: usize) -> Vec { .collect() } -/// Map FFT bins to log-spaced bands and squash into roughly 0..1. -/// -/// Normalization is the tricky bit: the raw `realfft` output is -/// **unnormalised** so a full-scale sine through a Hann window peaks -/// at `FFT_SIZE / 4`. We divide each band's RMS-like magnitude by -/// that factor so the result lives in 0..1, then apply a perceptual -/// `sqrt` curve + a small floor cut so quiet ambient still shows -/// a hint of motion without typical pop / rock pegging at the top. +/// Move the level reference toward this frame's loudest band: quickly +/// when it is louder, slowly when it is quieter, never below +/// [`MIN_LEVEL`]. One-pole smoothing, with the coefficient derived from +/// the real time since the previous frame so the feel does not depend on +/// the sample rate or on how the decoder happens to chunk its output. +fn follow_level(level: f32, frame_peak: f32, elapsed: Duration) -> f32 { + let tau = if frame_peak > level { + LEVEL_ATTACK + } else { + LEVEL_RELEASE + }; + let alpha = 1.0 - (-elapsed.as_secs_f32() / tau.as_secs_f32()).exp(); + (level + (frame_peak - level) * alpha).max(MIN_LEVEL) +} + +/// Magnitude of each log-spaced band: the peak bin inside it, or, for a +/// band narrower than a bin, the spectrum read at the band's centre +/// frequency by linear interpolation between the two nearest bins. /// -/// The earlier dB-based formula had no FFT normalisation and ended -/// up clipping every band to 1.0 on most music — see commit history. -fn compute_bands(spectrum: &[Complex], sample_rate: f32, bands: &mut [f32]) { +/// The peak rather than the mean: averaging across 20+ bins crushes the +/// very transients that make a visualizer feel alive. The interpolation +/// is what keeps two adjacent narrow bands from reporting the same bin — +/// they read the slope between bins at two different points instead. +fn band_magnitudes(spectrum: &[Complex], sample_rate: f32, bands: &mut [f32]) { let bin_count = spectrum.len(); if bin_count == 0 { bands.fill(0.0); return; } let bin_hz = sample_rate / (FFT_SIZE as f32); - // Empirical reference for "loud bin magnitude": for typical - // post-Hann music FFT a single dominant bin sits in the 50-300 - // range. We scale so ~250 maps to 1.0, leaving headroom for - // very loud transients to peg without clipping the visual - // weeks-of-the-music. (The full theoretical peak is FFT_SIZE/4 - // for a unit sine, but real music never concentrates that - // much energy in a single bin.) - let norm_peak = 250.0_f32; - // Quiet floor: anything below this is treated as silence so the - // renderer shows zero instead of a constant low-amplitude haze - // from quantisation / decoder rounding. - const FLOOR: f32 = 0.02; - let log_min = MIN_HZ.ln(); let log_max = MAX_HZ.ln(); let band_count = bands.len(); - for (b, band) in bands.iter_mut().enumerate().take(band_count) { + for (b, band) in bands.iter_mut().enumerate() { let lo_hz = (log_min + (log_max - log_min) * b as f32 / band_count as f32).exp(); let hi_hz = (log_min + (log_max - log_min) * (b + 1) as f32 / band_count as f32).exp(); - let lo_bin = (lo_hz / bin_hz).floor() as usize; - let hi_bin = ((hi_hz / bin_hz).ceil() as usize).max(lo_bin + 1); + let lo = lo_hz / bin_hz; + let hi = hi_hz / bin_hz; - // Use the per-band peak rather than the mean — averaging - // across 20+ bins crushes the very transients that make a - // visualizer feel alive. A single strong bin should still - // drive its band bar to full height. + if hi - lo < 1.0 { + // Narrower than a bin: read the spectrum at the geometric + // centre of the band. + let centre = (lo * hi).sqrt(); + let i = (centre.floor() as usize).min(bin_count - 1); + let j = (i + 1).min(bin_count - 1); + let t = centre - i as f32; + *band = spectrum[i].norm() * (1.0 - t) + spectrum[j].norm() * t; + continue; + } + + // Every bin whose centre falls inside the band. At least one + // does, since the band is a bin wide or more. + let lo_bin = lo.ceil() as usize; + let hi_bin = (hi.floor() as usize + 1).max(lo_bin + 1).min(bin_count); let mut peak_sq = 0.0f32; - for bin_val in spectrum.iter().take(hi_bin.min(bin_count)).skip(lo_bin) { - let m = bin_val.norm_sqr(); - if m > peak_sq { - peak_sq = m; - } + for bin_val in spectrum.iter().take(hi_bin).skip(lo_bin) { + peak_sq = peak_sq.max(bin_val.norm_sqr()); } - let mag = peak_sq.sqrt(); - let normalised = (mag / norm_peak).clamp(0.0, 1.0); + *band = peak_sq.sqrt(); + } +} + +/// Squash raw band magnitudes into 0..1 against the level reference. +/// +/// The reference used to be a fixed `250.0`, "a loud bin on typical +/// music", so a quietly mastered track — or one that spreads its energy +/// rather than concentrating it — never came near the top. Scaling +/// against the track's own recent level (with [`HEADROOM`] above it) +/// makes the peaks of the music reach the top whatever the master. +/// +/// A small floor then treats the bottom as silence, so quantisation and +/// decoder rounding show as zero rather than a constant haze, and a +/// `sqrt` curve expands the low end, where the ear is most sensitive to +/// loudness changes. +fn scale_bands(raw: &[f32], level: f32, bands: &mut [f32]) { + const FLOOR: f32 = 0.02; + let reference = level.max(MIN_LEVEL) * HEADROOM; + for (band, &mag) in bands.iter_mut().zip(raw) { + let normalised = (mag / reference).clamp(0.0, 1.0); let cut = (normalised - FLOOR).max(0.0) / (1.0 - FLOOR); - // Perceptual curve — sqrt expands the low end where the - // human ear is most sensitive to loudness changes. *band = cut.sqrt(); } } @@ -277,10 +341,59 @@ mod tests { #[test] fn empty_spectrum_produces_zero_bands() { let mut bands = vec![1.0; BAND_COUNT]; - compute_bands(&[], 44_100.0, &mut bands); + band_magnitudes(&[], 44_100.0, &mut bands); assert!(bands.iter().all(|&v| v == 0.0)); } + /// The #715 defect: adjacent bass bands reading the very same bin. + /// A sloped spectrum must give every band its own value. + #[test] + fn no_two_bass_bands_read_the_same_value() { + let spectrum: Vec> = (0..=FFT_SIZE / 2) + .map(|i| Complex::new(1000.0 / (1.0 + i as f32), 0.0)) + .collect(); + let mut bands = vec![0.0; BAND_COUNT]; + band_magnitudes(&spectrum, 44_100.0, &mut bands); + for pair in bands[..12].windows(2) { + assert!( + (pair[0] - pair[1]).abs() > 1e-3, + "adjacent bass bands must differ: {pair:?}" + ); + } + } + + #[test] + fn a_quiet_master_still_reaches_the_top() { + // A frame peak far below the old fixed reference of 250. + let mut level = MIN_LEVEL; + for _ in 0..60 { + level = follow_level(level, 60.0, EMIT_INTERVAL); + } + let mut bands = [0.0; 1]; + scale_bands(&[60.0], level, &mut bands); + assert!(bands[0] > 0.9, "got {}", bands[0]); + } + + #[test] + fn silence_is_not_amplified_into_noise() { + let mut level = 400.0; + for _ in 0..600 { + level = follow_level(level, 0.5, EMIT_INTERVAL); + } + assert!(level >= MIN_LEVEL); + let mut bands = [1.0; 1]; + scale_bands(&[0.5], level, &mut bands); + assert_eq!(bands[0], 0.0); + } + + #[test] + fn a_single_beat_does_not_reset_the_scale() { + // Rising takes a fraction of a second: one loud frame moves the + // reference only part of the way, so the beat itself pegs. + let level = follow_level(100.0, 1000.0, EMIT_INTERVAL); + assert!(level < 300.0, "got {level}"); + } + #[test] fn analyzer_starts_idle_and_resets_clean() { let mut a = SpectrumAnalyzer::new(); diff --git a/src-tauri/crates/app/src/commands/artist_split.rs b/src-tauri/crates/app/src/commands/artist_split.rs index 8bcc9518..0de59199 100644 --- a/src-tauri/crates/app/src/commands/artist_split.rs +++ b/src-tauri/crates/app/src/commands/artist_split.rs @@ -55,13 +55,20 @@ pub struct SplitArtistResult { /// Fails with a clear message when the name has no comma-separated parts /// (nothing to split) or when every part canonicalises back to the /// phantom itself. +/// +/// `expected_profile_id` pins the split to the profile the caller read +/// `artist_id` from: artist ids are per-profile, so a split sent just as +/// the profile switched would otherwise land on whichever artist holds +/// that id in the new one. `None` keeps the old behaviour (the artist +/// page, whose id is the active profile's by construction). #[tauri::command] pub async fn split_artist( app: AppHandle, state: tauri::State<'_, AppState>, artist_id: i64, + expected_profile_id: Option, ) -> AppResult { - let pool = state.require_profile_pool().await?; + let pool = state.require_profile_pool_for(expected_profile_id).await?; let result = split_artist_inner(&pool, artist_id).await?; // Announce it, like every other command that rewrites library rows @@ -83,6 +90,21 @@ pub async fn split_artist( /// DB-only core of [`split_artist`], split out so integration tests can /// drive it against a migrated in-memory profile DB without a Tauri /// `AppState`. +/// The names a split of `name` would produce, in tag order. +/// +/// Comma-split — the deliberate opposite of the scanner's `"; "`-only +/// policy, gated behind an explicit user action so a real name like +/// "Tyler, The Creator" is never fragmented without intent. Shared with +/// the inventory's list of artists to split (#719), so what that list +/// offers is exactly what the split then does. +pub(crate) fn split_fragments(name: &str) -> Vec { + name.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + pub(crate) async fn split_artist_inner( pool: &SqlitePool, artist_id: i64, @@ -93,15 +115,7 @@ pub(crate) async fn split_artist_inner( .await? .ok_or_else(|| AppError::Other(format!("artist {artist_id} not found")))?; - // Comma-split — the deliberate opposite of the scanner's `"; "`-only - // policy, gated behind this explicit user action so a real name like - // "Tyler, The Creator" is never fragmented without intent. - let parts: Vec = name - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); + let parts = split_fragments(&name); if parts.len() < 2 { return Err(AppError::Other( "this artist name has no comma-separated parts to split".into(), diff --git a/src-tauri/crates/app/src/commands/inventory.rs b/src-tauri/crates/app/src/commands/inventory.rs index f7f66c88..8e3aa691 100644 --- a/src-tauri/crates/app/src/commands/inventory.rs +++ b/src-tauri/crates/app/src/commands/inventory.rs @@ -43,6 +43,9 @@ use sqlx::Row; use waveflow_core::inventory::{chain_by_duration, DURATION_TOLERANCE_MS}; use waveflow_core::metadata::name_match::normalize_name; +use waveflow_core::scanner::canonical_name; + +use super::artist_split::split_fragments; use super::browse::{ expand_library_track_rows, library_track_order_clause, library_tracks_sql_where, LibraryTrackRawRow, ListLibraryTracksResponse, @@ -202,11 +205,148 @@ const CATEGORIES: &[&str] = &[ "duplicate_track_number", "track_number_gap", "probable_duplicate", + PHANTOM, ]; /// The key whose members are computed in Rust rather than in SQL. const PROBABLE: &str = "probable_duplicate"; +/// The one category made of artists rather than tracks (#719): names that +/// look like several artists joined by commas. Listed by +/// [`inventory_phantom_artists`]; [`inventory_tracks`] has nothing for it. +const PHANTOM: &str = "phantom_artist"; + +/// `profile_setting` key: JSON array of the canonical names the user said +/// are one artist ("don't split"), so the list stops asking about them. +pub const DISMISSED_PHANTOMS_KEY: &str = "inventory.dismissed_phantoms"; + +/// One name a phantom would split into. +#[derive(Debug, Clone, Serialize)] +pub struct PhantomFragment { + pub name: String, + /// The artist already in the library under that name, if any — the + /// row the split will reuse. + pub artist_id: Option, +} + +/// An artist whose name looks like several joined by commas. +#[derive(Debug, Clone, Serialize)] +pub struct PhantomArtist { + pub id: i64, + pub name: String, + /// What `don't split` stores, so it matches what this list reads. + pub canonical_name: String, + pub track_count: i64, + pub fragments: Vec, +} + +/// Artists whose name is several names joined by commas, most likely +/// phantoms first. +/// +/// **Every comma-joined name is listed**, not only those whose fragments +/// already exist as artists — which is what the issue proposed, and what +/// measuring against real libraries ruled out: in one of them, 18 of 39 +/// comma names had no fragment in the library at all, and every one of +/// them was a real duo (`Drake, Lil Durk`). The fragments that do exist +/// are shown and sort a name up, as evidence rather than as a gate. +/// +/// A comma is still only a hint (`Tyler, The Creator`), so the user can +/// say "don't split" once; that is remembered per profile, by canonical +/// name so a rescan that recreates the row does not bring it back. +/// +/// Only artists credited on an available track: a phantom nothing plays +/// is not worth an entry. +async fn phantom_artists(pool: &sqlx::SqlitePool) -> AppResult> { + let candidates: Vec<(i64, String, String, i64)> = sqlx::query_as( + "SELECT a.id, a.name, a.canonical_name, COUNT(DISTINCT t.id) + FROM artist a + JOIN track_artist ta ON ta.artist_id = a.id + JOIN track t ON t.id = ta.track_id AND t.is_available = 1 + WHERE instr(a.name, ',') > 0 + GROUP BY a.id", + ) + .fetch_all(pool) + .await?; + if candidates.is_empty() { + return Ok(Vec::new()); + } + + let dismissed: Vec = + sqlx::query_scalar::<_, String>("SELECT value FROM profile_setting WHERE key = ?") + .bind(DISMISSED_PHANTOMS_KEY) + .fetch_optional(pool) + .await? + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + + let known: std::collections::HashMap = + sqlx::query_as::<_, (String, i64)>("SELECT canonical_name, id FROM artist") + .fetch_all(pool) + .await? + .into_iter() + .collect(); + + let mut out = Vec::new(); + for (id, name, canonical, track_count) in candidates { + if dismissed.contains(&canonical) { + continue; + } + let fragments: Vec = split_fragments(&name) + .into_iter() + .map(|fragment| { + let artist_id = known + .get(&canonical_name(&fragment)) + .copied() + .filter(|found| *found != id); + PhantomFragment { + name: fragment, + artist_id, + } + }) + .collect(); + // The same test the split applies: at least two distinct names, + // none of them the artist itself. + let mut distinct: Vec = fragments.iter().map(|f| canonical_name(&f.name)).collect(); + distinct.sort(); + distinct.dedup(); + distinct.retain(|c| !c.is_empty() && *c != canonical); + if distinct.len() < 2 { + continue; + } + out.push(PhantomArtist { + id, + name, + canonical_name: canonical, + track_count, + fragments, + }); + } + + let known_count = + |p: &PhantomArtist| p.fragments.iter().filter(|f| f.artist_id.is_some()).count(); + out.sort_by(|a, b| { + known_count(b) + .cmp(&known_count(a)) + .then(b.track_count.cmp(&a.track_count)) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(out) +} + +/// The artists the "artists to split" category holds (#719). +#[tauri::command] +/// +/// `expected_profile_id` is the profile the caller will label the rows +/// with, so they can never be read from one profile and acted on as +/// another's — see [`super::artist_split::split_artist`]. +pub async fn inventory_phantom_artists( + state: tauri::State<'_, AppState>, + expected_profile_id: Option, +) -> AppResult> { + let pool = state.require_profile_pool_for(expected_profile_id).await?; + phantom_artists(&pool).await +} + /// Track ids that are probably the same recording as some other track. /// /// Two passes on purpose. SQL groups by an exact normalized @@ -290,6 +430,8 @@ pub async fn inventory_summary( for key in CATEGORIES { let count = if *key == PROBABLE { probable_duplicate_ids(&pool).await?.len() as i64 + } else if *key == PHANTOM { + phantom_artists(&pool).await?.len() as i64 } else { let Some(clause) = where_for(key) else { continue; @@ -621,4 +763,75 @@ mod tests { // collide if the check grouped by album alone. assert_eq!(ids_for(&pool, "duplicate_track_number").await, vec![9, 10]); } + + /// #719, against the real migrations. Four comma names: + /// + /// - `Ice Spice, Central Cee`, one fragment already an artist: listed + /// first, with that fragment linked; + /// - `Drake, Lil Durk`, no fragment in the library: still listed — + /// measured on a real library, that is what most real duos look like; + /// - `Tyler, The Creator`, dismissed by the user: not listed; + /// - `Solo,` which splits into one name only: not a split at all. + /// + /// And `Unplayed, Duo`, credited on no available track: not listed. + #[tokio::test] + async fn comma_names_are_listed_unless_dismissed_or_unsplittable() { + let pool = pool().await; + sqlx::raw_sql( + r#"INSERT INTO library (id, name, color_id, icon_id, created_at, updated_at, + hlc_wall, hlc_logical) + VALUES (1, 'L', 1, 1, 0, 0, 0, 0); + INSERT INTO artist (id, name, canonical_name) VALUES + (1, 'Ice Spice, Central Cee', 'ice spice central cee'), + (2, 'Ice Spice', 'ice spice'), + (3, 'Drake, Lil Durk', 'drake lil durk'), + (4, 'Tyler, The Creator', 'tyler the creator'), + (5, 'Solo,', 'solo'), + (6, 'Unplayed, Duo', 'unplayed duo'); + INSERT INTO profile_setting (key, value, value_type, updated_at) + VALUES ('inventory.dismissed_phantoms', '["tyler the creator"]', 'json', 0);"#, + ) + .execute(&pool) + .await + .unwrap(); + for (id, artist, available) in [ + (1i64, 1i64, 1i64), + (2, 1, 1), + (3, 3, 1), + (4, 4, 1), + (5, 5, 1), + (6, 6, 0), + ] { + sqlx::query( + "INSERT INTO track (id, library_id, file_path, file_hash, file_size, + file_modified, title, primary_artist, duration_ms, + added_at, is_available, hlc_wall, hlc_logical, + rating_hlc_wall, rating_hlc_logical) + VALUES (?, 1, ?, ?, 1, 0, 'T', ?, 300000, 0, ?, 0, 0, 0, 0)", + ) + .bind(id) + .bind(format!("/p/{id}.flac")) + .bind(format!("p{id}")) + .bind(artist) + .bind(available) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO track_artist (track_id, artist_id, position) VALUES (?, ?, 0)", + ) + .bind(id) + .bind(artist) + .execute(&pool) + .await + .unwrap(); + } + + let listed = phantom_artists(&pool).await.unwrap(); + let names: Vec<&str> = listed.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["Ice Spice, Central Cee", "Drake, Lil Durk"]); + assert_eq!(listed[0].track_count, 2); + assert_eq!(listed[0].fragments[0].artist_id, Some(2)); + assert_eq!(listed[0].fragments[1].artist_id, None); + } } diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 48102f4a..a673e7b5 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -1150,6 +1150,7 @@ pub fn run() { commands::player::player_set_match_source_rate, commands::inventory::inventory_summary, commands::inventory::inventory_tracks, + commands::inventory::inventory_phantom_artists, commands::tag_fetch::search_album_tag_sources, commands::tag_fetch::fetch_album_tag_proposals, commands::track_tags::list_track_tag_keys, diff --git a/src/components/player/MotionCoverOverlay.tsx b/src/components/player/MotionCoverOverlay.tsx index 627cdc4d..0c2081cf 100644 --- a/src/components/player/MotionCoverOverlay.tsx +++ b/src/components/player/MotionCoverOverlay.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { useAlbumMotionArtwork } from "../../hooks/useAlbumMotionArtwork"; +import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; const ROUND: Record<"md" | "lg" | "xl" | "2xl", string> = { md: "rounded-md", @@ -21,6 +22,10 @@ const ROUND: Record<"md" | "lg" | "xl" | "2xl", string> = { * * The video is decorative (`aria-hidden`) — the accessible name lives on * the `` it sits over. + * + * Nothing renders under `prefers-reduced-motion`, as for a Canvas: the + * gate lives here rather than at each call site, where the Now Playing + * panel, the immersive view and the mini-player had each left it out. */ export function MotionCoverOverlay({ artist, @@ -38,7 +43,8 @@ export function MotionCoverOverlay({ className?: string; }) { const motion = useAlbumMotionArtwork(artist, album, albumId); - if (!motion) return null; + const reducedMotion = usePrefersReducedMotion(); + if (!motion || reducedMotion) return null; // Key on the URL so switching album remounts the video and resets the // ready/failed state below. return ( diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 21d4b752..e0fbcf02 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -10,6 +10,7 @@ import { createPortal } from "react-dom"; import { PlaylistGrid } from "./library/PlaylistGrid"; import { InventoryCategories } from "./library/InventoryCategories"; +import { PhantomArtistList } from "./library/PhantomArtistList"; import { TrackTableHeader } from "./library/TrackTableHeader"; import { ColumnPicker } from "./library/ColumnPicker"; import { useTrackColumns } from "../../hooks/useTrackColumns"; @@ -635,6 +636,8 @@ export function LibraryView({ // category the user has left must not paint. useEffect(() => { if (activeTab !== "needs-attention" || inventoryCategory == null) return; + // The one category made of artists lists itself (#719). + if (inventoryCategory === "phantom_artist") return; // Same gate the other lists use: firing before the stored sort has // been read loads the whole category once in the default order and // again in the right one. @@ -1671,7 +1674,15 @@ export function LibraryView({ point and not a report: same columns, same sort, same context menu, same properties modal -- which is what makes fixing a track from here possible at all. */} + {inventoryCategory === "phantom_artist" && ( + setEditRefetch((k) => k + 1)} + t={t} + /> + )} {inventoryCategory != null && + inventoryCategory !== "phantom_artist" && renderTrackTable(inventoryRows, inventoryBusy, (index) => { void playTracks(inventoryRows.map(toLocalTrack), index, { type: "library", diff --git a/src/components/views/MiniPlayer.tsx b/src/components/views/MiniPlayer.tsx index 24c577ae..548cc020 100644 --- a/src/components/views/MiniPlayer.tsx +++ b/src/components/views/MiniPlayer.tsx @@ -46,6 +46,11 @@ import { } from "../../lib/playerSources"; import { Artwork } from "../common/Artwork"; import { CoverSlideshow } from "../player/CoverSlideshow"; +import { CanvasStage } from "../player/CanvasStage"; +import { MotionCoverOverlay } from "../player/MotionCoverOverlay"; +import { useTrackCanvas } from "../../hooks/useTrackCanvas"; +import { useCanvasEnabled } from "../../hooks/useCanvasEnabled"; +import { useAlbumMotionArtwork } from "../../hooks/useAlbumMotionArtwork"; import { resolveArtwork } from "../../lib/tauri/artwork"; import { dominantColor, darken, rgb } from "../../lib/dominantColor"; import { formatDuration } from "../../lib/tauri/track"; @@ -251,20 +256,39 @@ export function MiniPlayer() { [isRemoteSession], ); + // ── Canvas and motion cover (issue #717) ────────────── + // The same chain as the Now Playing panel and the immersive view: + // Canvas > motion cover > slideshow > still cover. This window used to + // stop at the slideshow, which made the gap more visible, not less. + // + // It is a second video decode while the main window may be playing the + // same clip — the cost of showing it here at all. The Show Canvas + // toggle and reduced motion gate it exactly as they do elsewhere, and + // the clip is cropped into the square slot: at this size a tall frame + // would push the controls out of the window. + const canvasEnabled = useCanvasEnabled(); + const reducedMotion = usePrefersReducedMotion(); + const canvasPath = useTrackCanvas(currentTrack); + const canvasActive = canvasEnabled && !reducedMotion && !!canvasPath; + const motionCover = useAlbumMotionArtwork( + currentTrack?.artist_name, + currentTrack?.album_title, + currentTrack?.album_id, + ); + // ── Cover ↔ artist slideshow (issue #702) ────────────── // The window people leave on screen while doing something else is // arguably where a slowly alternating cover is nicest — a beta - // tester asked for it (#700). Nothing outranks it here: this window - // renders no Canvas and no motion cover, so the precedence chain the - // other two surfaces pass in (Canvas > motion > slideshow > cover) has - // nothing to say, and `blocked` stays at its default. + // tester asked for it (#700). One rung below the clips above. // // The photo is resolved from this webview, which means a second // `enrich_artist_deezer` for the same artist while the main window // shows one too — a cache hit against `app.metadata_artist`, not a // second network fetch. It costs one IPC round-trip per artist // change, and only for a profile that turned the slideshow on. - const slideshow = useSlideshowLayer(currentTrack); + const slideshow = useSlideshowLayer(currentTrack, { + blocked: canvasActive || !!motionCover, + }); // ── Cover-derived background gradient ─────────────────────────── const artworkUrl = useMemo(() => { @@ -594,9 +618,23 @@ export function MiniPlayer() { className="w-full h-full object-cover" rounded="xl" /> - {/* Sibling of the cover inside the slot's own - `relative` box, so the crossfade lands under the - hover controls rather than over them. */} + {/* Siblings of the cover inside the slot's own + `relative` box, so the clips and the crossfade + land under the hover controls rather than over + them. */} + {!canvasActive && ( + + )} + + )} diff --git a/src/components/views/library/InventoryCategories.tsx b/src/components/views/library/InventoryCategories.tsx index c6251d23..cc224916 100644 --- a/src/components/views/library/InventoryCategories.tsx +++ b/src/components/views/library/InventoryCategories.tsx @@ -8,6 +8,7 @@ import { ImageOff, ListX, Mic2Icon, + Split, TextCursorInput, Users, } from "lucide-react"; @@ -45,8 +46,12 @@ const ICONS: Record = { duplicate_track_number: ListX, track_number_gap: ListX, probable_duplicate: CopyCheck, + phantom_artist: Split, }; +/** Categories that count artists rather than tracks (#719). */ +const ARTIST_CATEGORIES = new Set(["phantom_artist"]); + /** See the note in `TrackTableHeader`. */ type Translator = (key: string, options?: Record) => string; @@ -126,7 +131,13 @@ export function InventoryCategories({ })} - {t("library.inventory.trackCount", { count: category.count })} + {ARTIST_CATEGORIES.has(category.key) + ? t("library.inventory.artistCount", { + count: category.count, + }) + : t("library.inventory.trackCount", { + count: category.count, + })} diff --git a/src/components/views/library/PhantomArtistList.tsx b/src/components/views/library/PhantomArtistList.tsx new file mode 100644 index 00000000..57f62481 --- /dev/null +++ b/src/components/views/library/PhantomArtistList.tsx @@ -0,0 +1,251 @@ +import { useEffect, useState } from "react"; +import { Check, Loader2, Split } from "lucide-react"; +import { + inventoryPhantomArtists, + type PhantomArtist, +} from "../../../lib/tauri/inventory"; +import { splitArtist } from "../../../lib/tauri/artistOverrides"; +import { useProfileSetting } from "../../../hooks/useProfileSetting"; +import { useProfile } from "../../../hooks/useProfile"; + +/** See the note in `TrackTableHeader`. */ +type Translator = (key: string, options?: Record) => string; + +const DISMISSED_KEY = "inventory.dismissed_phantoms"; +const DISMISSED_EVENT = "waveflow:inventory-dismissed-phantoms-changed"; +const NONE: string[] = []; + +function parseDismissed(raw: string | null): string[] { + if (raw == null) return NONE; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((v): v is string => typeof v === "string") + : NONE; + } catch { + return NONE; + } +} + +interface PhantomArtistListProps { + /** Bumped by the parent whenever the library changes underneath. */ + refreshKey: number; + /** Called after a split or a dismissal, so the counts re-run. */ + onChanged: () => void; + t: Translator; +} + +/** + * The "artists to split" category of the inventory (#719): artists whose + * name looks like several joined by commas, each with what the split + * would produce and the action inline — no detour through the artist + * page. + * + * A comma is only a hint (`Tyler, The Creator`), so every row also offers + * "don't split", remembered per profile. The fragments already in the + * library are marked: they are the evidence, and the rows the split will + * reuse. + * + * Splitting takes a second click to confirm. It relinks every track and + * deletes the joined artist, and a list invites going down it quickly. + */ +export function PhantomArtistList({ + refreshKey, + onChanged, + t, +}: PhantomArtistListProps) { + // Artist ids belong to one profile's database. The rows carry the + // profile they were read from, and neither action runs against another: + // after a switch, a split on a stale row would split whichever artist + // holds that id in the new profile. + const profileId = useProfile().activeProfile?.id ?? null; + const [loaded, setLoaded] = useState<{ + profileId: number | null; + rows: PhantomArtist[]; + }>({ profileId: null, rows: [] }); + const current = loaded.profileId === profileId; + const rows = current ? loaded.rows : []; + const setRows = (update: (prev: PhantomArtist[]) => PhantomArtist[]) => + setLoaded((prev) => ({ ...prev, rows: update(prev.rows) })); + const [loading, setLoading] = useState(true); + const [armed, setArmed] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const dismissed = useProfileSetting({ + key: DISMISSED_KEY, + defaultValue: NONE, + parse: parseDismissed, + serialize: (names) => JSON.stringify(names), + valueType: "json", + event: DISMISSED_EVENT, + label: "PhantomArtistList", + }); + + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + inventoryPhantomArtists(profileId) + .then((list) => { + if (!cancelled) setLoaded({ profileId, rows: list }); + }) + .catch((err) => { + if (!cancelled) console.error("[PhantomArtistList] load failed", err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [refreshKey, dismissed.value, profileId]); + + // A confirmation armed in one profile must not carry into the next, + // where the same id can be another artist, nor an error with it. + useEffect(() => { + /* eslint-disable react-hooks/set-state-in-effect */ + setArmed(null); + setError(null); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [profileId]); + + // An armed "confirm" disarms itself, so a stray click minutes later + // cannot split an artist the user has long stopped looking at. + useEffect(() => { + if (armed == null) return; + const timer = window.setTimeout(() => setArmed(null), 4000); + return () => window.clearTimeout(timer); + }, [armed]); + + const split = async (artist: PhantomArtist) => { + if (!current) return; + if (armed !== artist.id) { + setArmed(artist.id); + return; + } + setArmed(null); + setBusy(artist.id); + setError(null); + // Captured before the await: by the time the split lands, another + // profile's rows may be showing, and the same id there is someone else. + const splitFor = loaded.profileId; + try { + await splitArtist(artist.id, splitFor); + setLoaded((prev) => + prev.profileId === splitFor + ? { ...prev, rows: prev.rows.filter((r) => r.id !== artist.id) } + : prev, + ); + onChanged(); + } catch (err) { + console.error("[PhantomArtistList] split failed", err); + setError(String(err)); + } finally { + setBusy(null); + } + }; + + const dismiss = (artist: PhantomArtist) => { + if (!current) return; + setRows((prev) => prev.filter((r) => r.id !== artist.id)); + void dismissed + .setValue((prev) => + prev.includes(artist.canonical_name) + ? prev + : [...prev, artist.canonical_name], + ) + .then(onChanged); + }; + + if (loading && rows.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {error && ( +

+ {error} +

+ )} +
    + {rows.map((artist) => ( +
  • +
    +
    + {artist.name} +
    +
    + {artist.fragments.map((fragment, i) => ( + + {fragment.artist_id != null && ( + + ))} + + {t("library.inventory.trackCount", { + count: artist.track_count, + })} + +
    +
    +
    + + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/views/settings/EstimatedKaraokeCard.tsx b/src/components/views/settings/EstimatedKaraokeCard.tsx new file mode 100644 index 00000000..dacb70b9 --- /dev/null +++ b/src/components/views/settings/EstimatedKaraokeCard.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from "react-i18next"; +import { Mic2 } from "lucide-react"; +import { useEstimatedKaraokeSetting } from "../../../hooks/useEstimatedKaraoke"; + +/** + * Settings → Lyrics row for the estimated word-by-word highlight on + * line-synced lyrics (#716). Default off; the subtitle says what it is — + * an estimate, drawn and never saved — so nobody mistakes it for real + * word timing. + */ +export function EstimatedKaraokeCard() { + const { t } = useTranslation(); + const { value, ready, setValue } = useEstimatedKaraokeSetting(); + + return ( +
+ +
+ ); +} diff --git a/src/components/views/settings/settingsCatalog.ts b/src/components/views/settings/settingsCatalog.ts index 0c524837..f3088bc4 100644 --- a/src/components/views/settings/settingsCatalog.ts +++ b/src/components/views/settings/settingsCatalog.ts @@ -296,6 +296,8 @@ export const SETTINGS_CATEGORIES = [ "settings.lyricsDestination.subtitle", "settings.fullscreenLyricsCentering.title", "settings.fullscreenLyricsCentering.subtitle", + "settings.lyricsEstimateWords.title", + "settings.lyricsEstimateWords.subtitle", ], }, ], diff --git a/src/hooks/useCanvasEnabled.ts b/src/hooks/useCanvasEnabled.ts index 1fe5bc16..55cdeda3 100644 --- a/src/hooks/useCanvasEnabled.ts +++ b/src/hooks/useCanvasEnabled.ts @@ -28,6 +28,21 @@ function subscribe(cb: () => void): () => void { return () => listeners.delete(cb); } +// The mini-player is a second webview on the same origin, so it shares +// this `localStorage` but not the in-memory copy above: without this, a +// toggle in the main window would not reach an open mini-player until it +// was reopened (#717). `storage` fires in every OTHER window of the origin, +// never in the one that wrote, so this cannot loop. +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key !== STORAGE_KEY) return; + const next = event.newValue === "true"; + if (next === enabled) return; + enabled = next; + for (const cb of listeners) cb(); + }); +} + function getSnapshot(): boolean { return enabled; } diff --git a/src/hooks/useEstimatedKaraoke.ts b/src/hooks/useEstimatedKaraoke.ts new file mode 100644 index 00000000..0f6165b9 --- /dev/null +++ b/src/hooks/useEstimatedKaraoke.ts @@ -0,0 +1,24 @@ +import { useProfileBooleanSetting } from "./useProfileSetting"; + +/** Broadcast after a write, so every mounted lyrics view re-reads. */ +export const ESTIMATED_KARAOKE_EVENT = "waveflow:lyrics-estimate-words-changed"; + +/** + * Whether line-synced lyrics get an estimated word-by-word highlight + * (#716), per profile in `profile_setting['lyrics.estimate_words']`. + * Default off: the estimate is a guess, and a guess drawn as if it were + * timing should be something the user asked for. + */ +export function useEstimatedKaraokeSetting() { + return useProfileBooleanSetting({ + key: "lyrics.estimate_words", + defaultValue: false, + event: ESTIMATED_KARAOKE_EVENT, + label: "useEstimatedKaraokeSetting", + }); +} + +/** Just the value, for the lyrics views. */ +export function useEstimatedKaraoke(): boolean { + return useEstimatedKaraokeSetting().value; +} diff --git a/src/hooks/useTrackLyrics.ts b/src/hooks/useTrackLyrics.ts index 36cbb1b6..69e56109 100644 --- a/src/hooks/useTrackLyrics.ts +++ b/src/hooks/useTrackLyrics.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { usePlayer } from "./usePlayer"; +import { useEstimatedKaraoke } from "./useEstimatedKaraoke"; +import { estimateLineWords } from "../lib/lyricsWordEstimate"; import { isRadioTrack, isRemoteTrack } from "../lib/playerSources"; import { pickFile } from "../lib/tauri/dialog"; import { remoteGetPlayQueue } from "../lib/tauri/remoteServer"; @@ -206,7 +208,7 @@ export function useTrackLyrics(): TrackLyrics { ]); // ── Parse lyrics once per content change ───────────────────────── - const lrcLines = useMemo(() => { + const parsedLines = useMemo(() => { if (!payload) return []; return parseLyrics(payload.content, payload.format); }, [payload]); @@ -215,16 +217,17 @@ export function useTrackLyrics(): TrackLyrics { // the fetched content is synced LRC — the stream position is "seconds // since I tuned in", not "seconds into the song", so a highlight would // be wrong. - const isSynced = !isRadio && lrcLines.length > 0; + const isSynced = !isRadio && parsedLines.length > 0; // For radio, strip the LRC timestamps for a clean static read: reuse // the parsed lines' text, or fall back to the raw content when it was // already plain. const radioPlainText = useMemo(() => { if (!isRadio || !payload) return null; - if (lrcLines.length > 0) return lrcLines.map((l) => l.text).join("\n"); + if (parsedLines.length > 0) + return parsedLines.map((l) => l.text).join("\n"); return payload.content; - }, [isRadio, payload, lrcLines]); + }, [isRadio, payload, parsedLines]); // ── Active-line tracking (auto-scroll lives in each consumer) ───── const [activeIndex, setActiveIndex] = useState(-1); @@ -235,14 +238,31 @@ export function useTrackLyrics(): TrackLyrics { return; } const idx = findActiveLineIndex( - lrcLines, + parsedLines, positionMs, Math.max(activeIndex, 0), ); if (idx !== activeIndex) { setActiveIndex(idx); } - }, [positionMs, lrcLines, isSynced, activeIndex]); + }, [positionMs, parsedLines, isSynced, activeIndex]); + + // Estimated word timing for line-synced lyrics (#716), opt-in. Only + // the active line gets words, re-derived each time a line becomes + // active and never stored, so a lyric the user reads plainly costs + // nothing and switching the setting off leaves no trace. A line that + // already carries real word timing is left exactly as it is. + const estimateWords = useEstimatedKaraoke(); + const lrcLines = useMemo(() => { + if (!estimateWords || !isSynced || activeIndex < 0) return parsedLines; + const line = parsedLines[activeIndex]; + if (!line || (line.words?.length ?? 0) > 0) return parsedLines; + const words = estimateLineWords(line, parsedLines[activeIndex + 1]?.timeMs); + if (words.length === 0) return parsedLines; + const next = parsedLines.slice(); + next[activeIndex] = { ...line, words }; + return next; + }, [estimateWords, isSynced, activeIndex, parsedLines]); // Active word inside the active line — only computed when the line // carries `words[]` so plain LRC stays cheap. diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 72c37173..6689e09e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -607,7 +607,9 @@ "summary_one": "{{count}} نقطة للتصحيح", "summary_other": "{{count}} نقطة للتصحيح", "trackCount_one": "مقطع واحد", + "artistCount_one": "فنان واحد", "trackCount_other": "{{count}} مقطع", + "artistCount_other": "{{count}} فنان", "categories": { "missing_title": { "label": "بلا عنوان", @@ -656,6 +658,10 @@ "probable_duplicate": { "label": "تكرار مرجّح", "help": "العنوان نفسه والفنان نفسه ومدة شبه متطابقة." + }, + "phantom_artist": { + "label": "فنانون للتقسيم", + "help": "أسماء تبدو كعدة فنانين مفصولين بفواصل." } }, "summary_zero": "لا نقاط للتصحيح", @@ -663,9 +669,19 @@ "summary_few": "{{count}} نقاط للتصحيح", "summary_many": "{{count}} نقطة للتصحيح", "trackCount_zero": "لا مقاطع", + "artistCount_zero": "لا فنانين", "trackCount_two": "مقطعان", + "artistCount_two": "فنانان", "trackCount_few": "{{count}} مقاطع", - "trackCount_many": "{{count}} مقطعًا" + "artistCount_few": "{{count}} فنانين", + "trackCount_many": "{{count}} مقطعًا", + "artistCount_many": "{{count}} فنانًا", + "phantoms": { + "split": "تقسيم", + "confirm": "تأكيد", + "dismiss": "لا تقسّم", + "known": "موجود في المكتبة" + } }, "columns": { "choose": "الأعمدة", @@ -2398,6 +2414,10 @@ "title": "توسيط الكلمات المتزامنة في وضع ملء الشاشة", "subtitle": "يضع الكلمات المتزامنة في منتصف عرض ملء الشاشة، مثل عرض النص العادي." }, + "lyricsEstimateWords": { + "title": "كاريوكي تقديري كلمة بكلمة", + "subtitle": "في الكلمات المتزامنة سطرًا بسطر، يبرز الكلمات وفق تقدير لمدة كل منها. للعرض فقط: لا يُحفظ شيء." + }, "lyricsTranslation": { "title": "ترجمة كلمات الأغنية (Musixmatch)", "subtitle": "تعرض الترجمة أسفل كل سطر LRC عندما يوفرها Musixmatch", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index a9dcfc93..f00e9e99 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} Punkt zu korrigieren", "summary_other": "{{count}} Punkte zu korrigieren", "trackCount_one": "{{count}} Titel", + "artistCount_one": "{{count}} Künstler", "trackCount_other": "{{count}} Titel", + "artistCount_other": "{{count}} Künstler", "categories": { "missing_title": { "label": "Ohne Titel", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "Wahrscheinliche Duplikate", "help": "Gleicher Titel, gleicher Interpret, fast gleiche Länge." + }, + "phantom_artist": { + "label": "Aufzuteilende Künstler", + "help": "Namen, die wie mehrere durch Kommas verbundene Künstler aussehen." } + }, + "phantoms": { + "split": "Aufteilen", + "confirm": "Bestätigen", + "dismiss": "Nicht aufteilen", + "known": "Bereits in der Mediathek" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "Synchronisierten Liedtext im Vollbild zentrieren", "subtitle": "Richtet zeitgesteuerten Liedtext in der Vollbildansicht zentriert aus, passend zur reinen Textansicht." }, + "lyricsEstimateWords": { + "title": "Geschätztes Wort-für-Wort-Karaoke", + "subtitle": "Hebt bei zeilenweise synchronisierten Songtexten die Wörter nach einer geschätzten Dauer hervor. Nur Anzeige: Es wird nichts gespeichert." + }, "lyricsTranslation": { "title": "Liedtextübersetzung (Musixmatch)", "subtitle": "Zeigt unter jeder LRC-Zeile eine Übersetzung an, sofern Musixmatch eine bereitstellt", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ae974d3c..274465c5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} thing to fix", "summary_other": "{{count}} things to fix", "trackCount_one": "{{count}} track", + "artistCount_one": "{{count}} artist", "trackCount_other": "{{count}} tracks", + "artistCount_other": "{{count}} artists", "categories": { "missing_title": { "label": "No title", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "Probable duplicates", "help": "Same title, same artist, nearly the same length." + }, + "phantom_artist": { + "label": "Artists to split", + "help": "Names that look like several artists joined by commas." } + }, + "phantoms": { + "split": "Split", + "confirm": "Confirm", + "dismiss": "Don't split", + "known": "Already in the library" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "Center synced lyrics in fullscreen", "subtitle": "Align time-coded lyrics to the center of the fullscreen overlay, matching the plain text view." }, + "lyricsEstimateWords": { + "title": "Estimated word-by-word karaoke", + "subtitle": "On line-synced lyrics, highlights the words from an estimate of how long each is sung. Display only: nothing is saved." + }, "lyricsTranslation": { "title": "Lyrics translation (Musixmatch)", "subtitle": "Displays a translation under each LRC line when Musixmatch has one", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9e270a92..36fc03f9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -529,7 +529,9 @@ "summary_one": "{{count}} punto por corregir", "summary_other": "{{count}} puntos por corregir", "trackCount_one": "{{count}} pista", + "artistCount_one": "{{count}} artista", "trackCount_other": "{{count}} pistas", + "artistCount_other": "{{count}} artistas", "categories": { "missing_title": { "label": "Sin título", @@ -578,7 +580,17 @@ "probable_duplicate": { "label": "Duplicados probables", "help": "Mismo título, mismo artista, duración casi idéntica." + }, + "phantom_artist": { + "label": "Artistas por separar", + "help": "Nombres que parecen varios artistas unidos por comas." } + }, + "phantoms": { + "split": "Separar", + "confirm": "Confirmar", + "dismiss": "No separar", + "known": "Ya está en la biblioteca" } }, "columns": { @@ -2212,6 +2224,10 @@ "title": "Centrar las letras sincronizadas en pantalla completa", "subtitle": "Alinea las letras sincronizadas al centro de la vista a pantalla completa, igual que el texto plano." }, + "lyricsEstimateWords": { + "title": "Karaoke palabra por palabra estimado", + "subtitle": "En letras sincronizadas por línea, resalta las palabras según una estimación de su duración. Solo visual: no se guarda nada." + }, "lyricsTranslation": { "title": "Traducción de letras (Musixmatch)", "subtitle": "Muestra una traducción bajo cada línea LRC cuando Musixmatch la tiene", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 03fc58f0..e57e2f78 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -529,7 +529,9 @@ "summary_one": "{{count}} point à corriger", "summary_other": "{{count}} points à corriger", "trackCount_one": "{{count}} titre", + "artistCount_one": "{{count}} artiste", "trackCount_other": "{{count}} titres", + "artistCount_other": "{{count}} artistes", "categories": { "missing_title": { "label": "Sans titre", @@ -578,7 +580,17 @@ "probable_duplicate": { "label": "Doublons probables", "help": "Même titre, même artiste, durée quasi identique." + }, + "phantom_artist": { + "label": "Artistes à scinder", + "help": "Des noms qui ressemblent à plusieurs artistes joints par des virgules." } + }, + "phantoms": { + "split": "Scinder", + "confirm": "Confirmer", + "dismiss": "Ne pas scinder", + "known": "Déjà dans la bibliothèque" } }, "columns": { @@ -2236,6 +2248,10 @@ "title": "Centrer les paroles synchronisées en plein écran", "subtitle": "Aligne les paroles synchronisées au centre de l'aperçu plein écran, comme l'affichage du texte brut." }, + "lyricsEstimateWords": { + "title": "Karaoké mot à mot estimé", + "subtitle": "Sur les paroles synchronisées ligne par ligne, surligne les mots selon une estimation de leur durée. Purement visuel : rien n'est enregistré." + }, "lyricsDestination": { "title": "Destination des paroles", "subtitle": "Où les paroles atterrissent sur le disque : celles que vous saisissez et celles que WaveFlow récupère. Vous pouvez toujours surcharger ce choix à chaque édition." diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 49f8b191..4b382032 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} बात सुधारने को", "summary_other": "{{count}} बातें सुधारने को", "trackCount_one": "{{count}} ट्रैक", + "artistCount_one": "{{count}} कलाकार", "trackCount_other": "{{count}} ट्रैक", + "artistCount_other": "{{count}} कलाकार", "categories": { "missing_title": { "label": "शीर्षक नहीं", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "संभावित डुप्लिकेट", "help": "वही शीर्षक, वही कलाकार, लगभग वही अवधि।" + }, + "phantom_artist": { + "label": "अलग करने लायक कलाकार", + "help": "ऐसे नाम जो कॉमा से जुड़े कई कलाकारों जैसे दिखते हैं।" } + }, + "phantoms": { + "split": "अलग करें", + "confirm": "पुष्टि करें", + "dismiss": "अलग न करें", + "known": "लाइब्रेरी में पहले से है" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "फ़ुल स्क्रीन में सिंक किए गए बोल को केंद्र में रखें", "subtitle": "फ़ुल स्क्रीन ओवरले में टाइम-कोडेड बोल को सादे टेक्स्ट दृश्य की तरह केंद्र में संरेखित करता है।" }, + "lyricsEstimateWords": { + "title": "अनुमानित शब्द-दर-शब्द कराओके", + "subtitle": "पंक्ति-दर-पंक्ति सिंक किए गए लिरिक्स में, हर शब्द की अवधि के अनुमान से शब्दों को हाइलाइट करता है। केवल दिखाने के लिए: कुछ भी सहेजा नहीं जाता।" + }, "lyricsTranslation": { "title": "गीत अनुवाद (Musixmatch)", "subtitle": "जब Musixmatch के पास उपलब्ध हो, तो प्रत्येक LRC पंक्ति के नीचे अनुवाद दिखाता है", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 3e699465..de0f9cc9 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} hal untuk diperbaiki", "summary_other": "{{count}} hal untuk diperbaiki", "trackCount_one": "{{count}} lagu", + "artistCount_one": "{{count}} artis", "trackCount_other": "{{count}} lagu", + "artistCount_other": "{{count}} artis", "categories": { "missing_title": { "label": "Tanpa judul", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "Kemungkinan duplikat", "help": "Judul sama, artis sama, durasi hampir sama." + }, + "phantom_artist": { + "label": "Artis untuk dipisah", + "help": "Nama yang tampak seperti beberapa artis yang digabung dengan koma." } + }, + "phantoms": { + "split": "Pisahkan", + "confirm": "Konfirmasi", + "dismiss": "Jangan pisahkan", + "known": "Sudah ada di pustaka" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "Pusatkan lirik tersinkron di layar penuh", "subtitle": "Menyelaraskan lirik tersinkron di tengah tampilan layar penuh, sama seperti teks polos." }, + "lyricsEstimateWords": { + "title": "Karaoke kata per kata perkiraan", + "subtitle": "Pada lirik yang disinkronkan per baris, menyorot kata berdasarkan perkiraan durasinya. Hanya tampilan: tidak ada yang disimpan." + }, "lyricsTranslation": { "title": "Terjemahan lirik (Musixmatch)", "subtitle": "Menampilkan terjemahan di bawah setiap baris LRC saat Musixmatch menyediakannya", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 22c4a205..4c596da3 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -529,7 +529,9 @@ "summary_one": "{{count}} punto da correggere", "summary_other": "{{count}} punti da correggere", "trackCount_one": "{{count}} brano", + "artistCount_one": "{{count}} artista", "trackCount_other": "{{count}} brani", + "artistCount_other": "{{count}} artisti", "categories": { "missing_title": { "label": "Senza titolo", @@ -578,7 +580,17 @@ "probable_duplicate": { "label": "Probabili duplicati", "help": "Stesso titolo, stesso artista, durata quasi identica." + }, + "phantom_artist": { + "label": "Artisti da separare", + "help": "Nomi che sembrano più artisti uniti da virgole." } + }, + "phantoms": { + "split": "Separa", + "confirm": "Conferma", + "dismiss": "Non separare", + "known": "Già nella libreria" } }, "columns": { @@ -2212,6 +2224,10 @@ "title": "Centra i testi sincronizzati a schermo intero", "subtitle": "Allinea i testi sincronizzati al centro della vista a schermo intero, come la versione testuale." }, + "lyricsEstimateWords": { + "title": "Karaoke parola per parola stimato", + "subtitle": "Sui testi sincronizzati per riga, evidenzia le parole in base a una stima della loro durata. Solo visivo: non viene salvato nulla." + }, "lyricsTranslation": { "title": "Traduzione dei testi (Musixmatch)", "subtitle": "Mostra una traduzione sotto ogni riga LRC quando Musixmatch ne fornisce una", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1bd87816..2cc150b2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -528,7 +528,9 @@ "summary_one": "要確認 {{count}}件", "summary_other": "要確認 {{count}}件", "trackCount_one": "{{count}}曲", + "artistCount_one": "{{count}} 組のアーティスト", "trackCount_other": "{{count}}曲", + "artistCount_other": "{{count}} 組のアーティスト", "categories": { "missing_title": { "label": "タイトルなし", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "重複の疑い", "help": "同じ曲名、同じアーティスト、ほぼ同じ長さ。" + }, + "phantom_artist": { + "label": "分割するアーティスト", + "help": "複数のアーティストがカンマで連結されているように見える名前です。" } + }, + "phantoms": { + "split": "分割", + "confirm": "確認", + "dismiss": "分割しない", + "known": "ライブラリに登録済み" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "フルスクリーンで同期歌詞を中央揃え", "subtitle": "フルスクリーン表示でタイムコード付き歌詞をプレーンテキスト表示と同じく中央に揃えます。" }, + "lyricsEstimateWords": { + "title": "推定の単語ごとカラオケ", + "subtitle": "行単位で同期された歌詞で、各単語の長さの推定に基づいてハイライトします。表示のみで、何も保存されません。" + }, "lyricsTranslation": { "title": "歌詞の翻訳 (Musixmatch)", "subtitle": "Musixmatch に翻訳がある場合、各 LRC 行の下に翻訳を表示します", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 3145fbed..6d87a6fa 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -528,7 +528,9 @@ "summary_one": "확인할 항목 {{count}}개", "summary_other": "확인할 항목 {{count}}개", "trackCount_one": "{{count}}곡", + "artistCount_one": "아티스트 {{count}}명", "trackCount_other": "{{count}}곡", + "artistCount_other": "아티스트 {{count}}명", "categories": { "missing_title": { "label": "제목 없음", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "중복 가능성", "help": "같은 제목, 같은 아티스트, 거의 같은 길이." + }, + "phantom_artist": { + "label": "분리할 아티스트", + "help": "여러 아티스트가 쉼표로 이어진 것처럼 보이는 이름입니다." } + }, + "phantoms": { + "split": "분리", + "confirm": "확인", + "dismiss": "분리하지 않음", + "known": "이미 보관함에 있음" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "전체 화면에서 동기화된 가사를 가운데 정렬", "subtitle": "전체 화면 보기에서 시간 동기화된 가사를 일반 텍스트처럼 가운데에 정렬합니다." }, + "lyricsEstimateWords": { + "title": "추정 단어별 가라오케", + "subtitle": "줄 단위로 동기화된 가사에서 각 단어의 길이를 추정해 강조합니다. 표시 전용이며 아무것도 저장되지 않습니다." + }, "lyricsTranslation": { "title": "가사 번역(Musixmatch)", "subtitle": "Musixmatch에 번역이 있을 때 각 LRC 줄 아래에 번역을 표시합니다", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index ea941c54..f3cb1d21 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} punt te verbeteren", "summary_other": "{{count}} punten te verbeteren", "trackCount_one": "{{count}} nummer", + "artistCount_one": "{{count}} artiest", "trackCount_other": "{{count}} nummers", + "artistCount_other": "{{count}} artiesten", "categories": { "missing_title": { "label": "Zonder titel", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "Waarschijnlijke duplicaten", "help": "Zelfde titel, zelfde artiest, bijna zelfde lengte." + }, + "phantom_artist": { + "label": "Artiesten om te splitsen", + "help": "Namen die eruitzien als meerdere artiesten, gescheiden door komma's." } + }, + "phantoms": { + "split": "Splitsen", + "confirm": "Bevestigen", + "dismiss": "Niet splitsen", + "known": "Al in de bibliotheek" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "Gesynchroniseerde songtekst centreren in volledig scherm", "subtitle": "Plaatst de gesynchroniseerde songtekst in het midden van de volledige weergave, zoals de gewone tekst." }, + "lyricsEstimateWords": { + "title": "Geschatte woord-voor-woord-karaoke", + "subtitle": "Markeert bij regel-gesynchroniseerde songteksten de woorden op basis van een schatting van hun duur. Alleen weergave: er wordt niets opgeslagen." + }, "lyricsTranslation": { "title": "Songtekstvertaling (Musixmatch)", "subtitle": "Toont onder elke LRC-regel een vertaling wanneer Musixmatch er een aanbiedt", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 6b9f4893..a906b62e 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -529,7 +529,10 @@ "summary_one": "{{count}} ponto a corrigir", "summary_other": "{{count}} pontos a corrigir", "trackCount_one": "{{count}} faixa", + "artistCount_zero": "0 artistas", + "artistCount_one": "{{count}} artista", "trackCount_other": "{{count}} faixas", + "artistCount_other": "{{count}} artistas", "categories": { "missing_title": { "label": "Sem título", @@ -578,7 +581,17 @@ "probable_duplicate": { "label": "Duplicatas prováveis", "help": "Mesmo título, mesmo artista, duração quase igual." + }, + "phantom_artist": { + "label": "Artistas para separar", + "help": "Nomes que parecem vários artistas unidos por vírgulas." } + }, + "phantoms": { + "split": "Separar", + "confirm": "Confirmar", + "dismiss": "Não separar", + "known": "Já está na biblioteca" } }, "columns": { @@ -2212,6 +2225,10 @@ "title": "Centralizar a letra sincronizada em tela cheia", "subtitle": "Alinha a letra sincronizada ao centro da tela cheia, como na visualização de texto simples." }, + "lyricsEstimateWords": { + "title": "Karaokê palavra por palavra estimado", + "subtitle": "Nas letras sincronizadas por linha, destaca as palavras segundo uma estimativa da duração de cada uma. Apenas visual: nada é salvo." + }, "lyricsTranslation": { "title": "Tradução de letras (Musixmatch)", "subtitle": "Mostra uma tradução abaixo de cada linha LRC quando o Musixmatch oferece uma", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 2726474c..81bfb5ea 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -529,7 +529,10 @@ "summary_one": "{{count}} ponto a corrigir", "summary_other": "{{count}} pontos a corrigir", "trackCount_one": "{{count}} faixa", + "artistCount_zero": "0 artistas", + "artistCount_one": "{{count}} artista", "trackCount_other": "{{count}} faixas", + "artistCount_other": "{{count}} artistas", "categories": { "missing_title": { "label": "Sem título", @@ -578,7 +581,17 @@ "probable_duplicate": { "label": "Duplicados prováveis", "help": "Mesmo título, mesmo artista, duração quase igual." + }, + "phantom_artist": { + "label": "Artistas a separar", + "help": "Nomes que parecem vários artistas unidos por vírgulas." } + }, + "phantoms": { + "split": "Separar", + "confirm": "Confirmar", + "dismiss": "Não separar", + "known": "Já está na biblioteca" } }, "columns": { @@ -2212,6 +2225,10 @@ "title": "Centrar as letras sincronizadas em ecrã inteiro", "subtitle": "Alinha as letras sincronizadas ao centro da vista de ecrã inteiro, tal como o texto simples." }, + "lyricsEstimateWords": { + "title": "Karaoke palavra a palavra estimado", + "subtitle": "Nas letras sincronizadas por linha, destaca as palavras segundo uma estimativa da sua duração. Apenas visual: nada é guardado." + }, "lyricsTranslation": { "title": "Tradução das letras (Musixmatch)", "subtitle": "Mostra uma tradução sob cada linha LRC quando o Musixmatch oferece uma", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 915f5183..82116110 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -578,7 +578,9 @@ "summary_one": "{{count}} замечание", "summary_other": "{{count}} замечаний", "trackCount_one": "{{count}} трек", + "artistCount_one": "{{count}} исполнитель", "trackCount_other": "{{count}} треков", + "artistCount_other": "{{count}} исполнителя", "categories": { "missing_title": { "label": "Без названия", @@ -627,12 +629,24 @@ "probable_duplicate": { "label": "Вероятные дубликаты", "help": "То же название, тот же исполнитель, почти та же длительность." + }, + "phantom_artist": { + "label": "Исполнители для разделения", + "help": "Имена, похожие на нескольких исполнителей через запятую." } }, "summary_few": "{{count}} замечания", "summary_many": "{{count}} замечаний", "trackCount_few": "{{count}} трека", - "trackCount_many": "{{count}} треков" + "artistCount_few": "{{count}} исполнителя", + "trackCount_many": "{{count}} треков", + "artistCount_many": "{{count}} исполнителей", + "phantoms": { + "split": "Разделить", + "confirm": "Подтвердить", + "dismiss": "Не разделять", + "known": "Уже в медиатеке" + } }, "columns": { "choose": "Столбцы", @@ -2323,6 +2337,10 @@ "title": "Центрировать синхронизированный текст в полноэкранном режиме", "subtitle": "Выравнивает синхронизированный текст по центру полноэкранного режима, как обычный текст." }, + "lyricsEstimateWords": { + "title": "Приблизительное караоке по словам", + "subtitle": "Для текстов, синхронизированных по строкам, подсвечивает слова по оценке их длительности. Только отображение: ничего не сохраняется." + }, "lyricsTranslation": { "title": "Перевод текстов песен (Musixmatch)", "subtitle": "Показывает перевод под каждой строкой LRC, когда Musixmatch его предоставляет", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 01025c6b..fb8c46df 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} sorun", "summary_other": "{{count}} sorun", "trackCount_one": "{{count}} parça", + "artistCount_one": "{{count}} sanatçı", "trackCount_other": "{{count}} parça", + "artistCount_other": "{{count}} sanatçı", "categories": { "missing_title": { "label": "Başlıksız", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "Olası kopyalar", "help": "Aynı başlık, aynı sanatçı, neredeyse aynı süre." + }, + "phantom_artist": { + "label": "Ayrılacak sanatçılar", + "help": "Virgülle birleştirilmiş birkaç sanatçıya benzeyen adlar." } + }, + "phantoms": { + "split": "Ayır", + "confirm": "Onayla", + "dismiss": "Ayırma", + "known": "Zaten kitaplıkta" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "Tam ekranda senkronize sözleri ortala", "subtitle": "Tam ekran görünümünde senkronize sözleri düz metin gibi ortaya hizalar." }, + "lyricsEstimateWords": { + "title": "Tahmini kelime kelime karaoke", + "subtitle": "Satır bazında senkronize sözlerde, kelimeleri tahmini sürelerine göre vurgular. Yalnızca görsel: hiçbir şey kaydedilmez." + }, "lyricsTranslation": { "title": "Şarkı sözü çevirisi (Musixmatch)", "subtitle": "Musixmatch çevirisi varsa her LRC satırının altında çeviriyi gösterir", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b16c488e..58196250 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} 处待修正", "summary_other": "{{count}} 处待修正", "trackCount_one": "{{count}} 首", + "artistCount_one": "{{count}} 位艺人", "trackCount_other": "{{count}} 首", + "artistCount_other": "{{count}} 位艺人", "categories": { "missing_title": { "label": "没有标题", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "疑似重复", "help": "标题相同、艺人相同、时长几乎一致。" + }, + "phantom_artist": { + "label": "待拆分的艺人", + "help": "看起来像是用逗号连接的多位艺人的名称。" } + }, + "phantoms": { + "split": "拆分", + "confirm": "确认", + "dismiss": "不拆分", + "known": "已在音乐库中" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "全屏中将同步歌词居中", "subtitle": "在全屏视图中将带时间戳的歌词居中对齐,与纯文本视图保持一致。" }, + "lyricsEstimateWords": { + "title": "估算的逐词卡拉 OK", + "subtitle": "在按行同步的歌词上,根据估算的时长逐词高亮。仅用于显示:不会保存任何内容。" + }, "lyricsTranslation": { "title": "歌词翻译 (Musixmatch)", "subtitle": "当 Musixmatch 提供翻译时,在每行 LRC 下方显示翻译", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index bc5b9976..f8db8428 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -528,7 +528,9 @@ "summary_one": "{{count}} 處待修正", "summary_other": "{{count}} 處待修正", "trackCount_one": "{{count}} 首", + "artistCount_one": "{{count}} 位藝人", "trackCount_other": "{{count}} 首", + "artistCount_other": "{{count}} 位藝人", "categories": { "missing_title": { "label": "沒有標題", @@ -577,7 +579,17 @@ "probable_duplicate": { "label": "疑似重複", "help": "標題相同、藝人相同、時長幾乎一致。" + }, + "phantom_artist": { + "label": "待拆分的藝人", + "help": "看起來像是以逗號連接的多位藝人的名稱。" } + }, + "phantoms": { + "split": "拆分", + "confirm": "確認", + "dismiss": "不拆分", + "known": "已在音樂庫中" } }, "columns": { @@ -2209,6 +2221,10 @@ "title": "全螢幕中將同步歌詞置中", "subtitle": "在全螢幕檢視中將帶時間戳的歌詞置中對齊,與純文字檢視保持一致。" }, + "lyricsEstimateWords": { + "title": "估算的逐字卡拉 OK", + "subtitle": "在逐行同步的歌詞上,依估算的長度逐字醒目提示。僅供顯示:不會儲存任何內容。" + }, "lyricsTranslation": { "title": "歌詞翻譯(Musixmatch)", "subtitle": "當 Musixmatch 有翻譯時,在每行 LRC 下方顯示翻譯", diff --git a/src/lib/lyricsWordEstimate.ts b/src/lib/lyricsWordEstimate.ts new file mode 100644 index 00000000..a95045de --- /dev/null +++ b/src/lib/lyricsWordEstimate.ts @@ -0,0 +1,115 @@ +import type { LyricsLine, LyricsWord } from "./tauri/lyrics"; + +/** + * Estimated word timing for a line-synced lyric (#716). + * + * Line-synced lyrics say when a line starts and nothing about the words + * inside it, so the karaoke fill never ran on them. This spreads the + * line's time over its words — **purely for display**: nothing here is + * stored, the estimate is re-derived every time a line becomes active, + * and only when the user opted in. + * + * Better than an equal split, which lets a one-syllable word hold the + * highlight as long as a four-syllable one: + * + * - **Weighted by syllables**, counted as vowel groups for alphabetic + * scripts. Rough, and wrong for silent vowels, but it tracks how long a + * word is sung far better than its letter count does. + * - **A pause after punctuation.** A comma or a full stop is where a + * singer breathes; the pause is dead time between two words, not time + * one of them holds. + * - **Whole phrases for scripts without spaces.** Every lyric view puts a + * space between two words, so splitting a Chinese or Japanese line per + * character would write spaces into it. Those lines step through their + * space-separated phrases instead, weighted one syllable per character; + * Korean keeps its spaces, one syllable per block. + * - **The sung span is capped.** A line followed by a long instrumental + * is not sung across the whole gap until the next line; the estimate + * ends where a plausible delivery would. + */ + +/** Seconds a syllable takes at most, before the span is capped. */ +const MAX_MS_PER_SYLLABLE = 550; +/** Floor for a guessed span, so a one-word last line still animates. */ +const MIN_SPAN_MS = 400; +/** Assumed span when the line's end is unknown (the last line). */ +const FALLBACK_MS_PER_SYLLABLE = 300; + +const PAUSE_SHORT = 0.5; // , ; : and dashes, in syllables +const PAUSE_LONG = 0.9; // . ! ? … + +const HAN_OR_KANA = + /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/u; +const HAN_OR_KANA_ALL = new RegExp(HAN_OR_KANA.source, "gu"); +const HANGUL = /[\uac00-\ud7af]/gu; +const VOWEL_GROUPS = + /[aeiouyàáâãäåæèéêëìíîïòóôõöøœùúûüýÿаеёиоуыэюяіїєαεηιουωάέήίόύώ]+/giu; + +/** Split a line into the units the highlight steps through. */ +function tokenize(text: string): string[] { + return text.split(/\s+/u).filter(Boolean); +} + +/** How many syllables a unit is sung over — at least one. */ +export function syllables(unit: string): number { + const han = unit.match(HAN_OR_KANA_ALL)?.length ?? 0; + if (han > 0) return han; + const hangul = unit.match(HANGUL)?.length ?? 0; + if (hangul > 0) return hangul; + const groups = unit.match(VOWEL_GROUPS)?.length ?? 0; + if (groups > 0) return groups; + // A script with no vowel table here, or a bare number: go by length. + const letters = Array.from(unit).filter((c) => /[\p{L}\p{N}]/u.test(c)); + return Math.max(1, Math.round(letters.length / 3)); +} + +/** Silence after a unit, in syllables, from its trailing punctuation. */ +function pauseAfter(unit: string): number { + if (/[.!?…。!?]["'’”)\]]*$/u.test(unit)) return PAUSE_LONG; + if (/[,;:—–、,;:]["'’”)\]]*$/u.test(unit)) return PAUSE_SHORT; + return 0; +} + +/** + * Words for `line`, spread over the time until `nextStartMs` (the next + * line's start, when there is one). Empty when the line has no text. + */ +export function estimateLineWords( + line: LyricsLine, + nextStartMs: number | undefined, +): LyricsWord[] { + const units = tokenize(line.text); + if (units.length === 0) return []; + + const weights = units.map(syllables); + const pauses = units.map((u, i) => + i < units.length - 1 ? pauseAfter(u) : 0, + ); + const total = + weights.reduce((a, b) => a + b, 0) + pauses.reduce((a, b) => a + b, 0); + + // The earlier of the two ends the line can have: its own, and the next + // line's start. A line whose stamped end overlaps the next must still + // hand over on time. + const ends = [line.endMs, nextStartMs].filter( + (end): end is number => end != null && end > line.timeMs, + ); + const knownEnd = ends.length > 0 ? Math.min(...ends) : null; + const span = + knownEnd != null + ? Math.min(knownEnd - line.timeMs, total * MAX_MS_PER_SYLLABLE) + : total * FALLBACK_MS_PER_SYLLABLE; + // The floor only where the end is a guess: with a known end, a short + // line is short, and stretching it would run into the next one. + const perUnit = + (knownEnd != null ? span : Math.max(span, MIN_SPAN_MS)) / total; + + const words: LyricsWord[] = []; + let cursor = line.timeMs; + units.forEach((text, i) => { + const end = cursor + weights[i] * perUnit; + words.push({ timeMs: Math.round(cursor), endMs: Math.round(end), text }); + cursor = end + pauses[i] * perUnit; + }); + return words; +} diff --git a/src/lib/tauri/artistOverrides.ts b/src/lib/tauri/artistOverrides.ts index 8127f3d6..fe86ad80 100644 --- a/src/lib/tauri/artistOverrides.ts +++ b/src/lib/tauri/artistOverrides.ts @@ -42,8 +42,16 @@ export interface SplitArtistResult { * enriched rows by canonical name. No file is re-tagged; the scanner has * a matching guard so an unchanged-file rescan won't undo the split. */ -export function splitArtist(artistId: number): Promise { - return invoke("split_artist", { artistId }); +export function splitArtist( + artistId: number, + /** The profile `artistId` was read from; the backend refuses the split + * if another one is active by then. Omit for the active profile. */ + expectedProfileId?: number | null, +): Promise { + return invoke("split_artist", { + artistId, + expectedProfileId: expectedProfileId ?? null, + }); } /** diff --git a/src/lib/tauri/inventory.ts b/src/lib/tauri/inventory.ts index 6bb371b4..c5d0b910 100644 --- a/src/lib/tauri/inventory.ts +++ b/src/lib/tauri/inventory.ts @@ -47,3 +47,33 @@ export async function inventoryTracks( }); return expandLibraryTrackRows(resp); } + +/** One name a comma-joined artist would split into (#719). */ +export interface PhantomFragment { + name: string; + /** The artist already in the library under that name, if any. */ + artist_id: number | null; +} + +/** An artist whose name looks like several joined by commas (#719). */ +export interface PhantomArtist { + id: number; + name: string; + /** What "don't split" stores, so it matches what the backend reads. */ + canonical_name: string; + track_count: number; + fragments: PhantomFragment[]; +} + +/** + * The "artists to split" category — the one inventory category made of + * artists rather than tracks, so it has its own list instead of going + * through `inventoryTracks`. Most likely phantoms first. + */ +export function inventoryPhantomArtists( + expectedProfileId: number | null, +): Promise { + return invoke("inventory_phantom_artists", { + expectedProfileId, + }); +}