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 `
- {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}
+
+ )}
+
+
+ );
+}
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,
+ });
+}