playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+111 -8
View File
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
// Rows are OCCURRENCES with their own ids: the same recording may appear
// twice, and every mutation addresses item ids, never positions.
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
export const setPlaylistDescription = (id, description) =>
invoke("set_playlist_description", { id, description: description ?? null });
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const playlistItems = (id) => invoke("playlist_items", { id });
export const playlistAddItems = (playlistId, items, at = null) =>
invoke("playlist_add_items", { playlistId, items, at });
export const playlistRemoveItems = (playlistId, itemIds) =>
invoke("playlist_remove_items", { playlistId, itemIds });
export const playlistSetOrder = (playlistId, orderedIds) =>
invoke("playlist_set_order", { playlistId, orderedIds });
// Repair one row: fields null = leave as stored; path non-null relinks the
// row to a playable file.
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
invoke("playlist_relink_item", {
playlistId,
itemId,
title: fields.title ?? null,
artist: fields.artist ?? null,
album: fields.album ?? null,
durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null,
artPath: fields.artPath ?? null,
});
// Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) =>
invoke("playlist_cover", { id, maxEdge });
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
// Import: probe opens a file dialog and stages a reviewed draft; text stages
// a pasted tracklist; commit writes the accepted draft in one transaction.
export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder");
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportCommit = (name, description, kind, entries) =>
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
// the written path, or null when the dialog was cancelled.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
export const lyricsGet = (args) =>
invoke("lyrics_get", {
title: args.title,
artist: args.artist,
album: args.album ?? null,
durationSecs: args.durationSecs ?? null,
path: args.path ?? null,
refresh: args.refresh ?? null,
});
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
export const lyricsSaveText = (title, artist, durationSecs, text) =>
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
export const lyricsClear = (title, artist, durationSecs) =>
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
// not a learned separator). Resolves to { path, cached } of a playable WAV.
export const lyricsPrepareInstrumental = (path) =>
invoke("lyrics_prepare_instrumental", { path });
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
// Persist hand-synced lines as the track's own document (wins the waterfall).
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait });
@@ -232,6 +303,11 @@ export const debugStats = () => invoke("debug_stats");
// source folder is never written to.
export const importScan = (root) => invoke("import_scan", { root });
export const importSessionGet = () => invoke("import_session_get");
// The source folder relative to the library folder, and back again. Saving
// both is what lets a review started on one machine resume on another after
// the drive lands at a different letter.
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
export const importSessionSet = (state) => invoke("import_session_set", { state });
export const importSessionClear = () => invoke("import_session_clear");
export const importGetDest = () => invoke("import_get_dest");
@@ -239,11 +315,29 @@ export const importSetDest = (dir, layout = null) => invoke("import_set_dest", {
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
// On-demand per-record identification (Deezer try-list → MB fallback) and
// the artwork panel's cover report. Both read-only.
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
// `title` is the record's own claim (tags); `folder` is the second witness,
// asked after it — a record whose tags are wrong still has its folder name.
// `shape` is everything ELSE the scan knows — track count, track titles, year,
// and where the files are so Rust can hash the record's own cover. That is what
// the discography pass compares when no phrase can find the record; the cover
// itself never crosses IPC (a master can be 8 MB).
export const importIdentify = (artist, title, folder = null, shape = {}) =>
invoke("import_identify", {
artist, title, folder,
tracks: shape.tracks ?? null,
trackTitles: shape.trackTitles ?? null,
year: shape.year ?? null,
sourceRoot: shape.sourceRoot ?? null,
rel: shape.rel ?? null,
// Bounds the cover hash to the record's own audio — a folded release
// gathers from these, an ordinary one from its folder alone.
parts: shape.parts ?? null,
});
export const importCoverReport = (artist, title, referenceUrl) =>
invoke("import_cover_report", { artist, title, referenceUrl });
// The record's own artwork, measured (embedded first, else folder images).
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
export const importLocalArt = (sourceRoot, rel, parts) =>
invoke("import_local_art", { sourceRoot, rel, parts });
// Prune rows whose files are gone, then index anything new — the real rescan.
export const rescanLibrary = () => invoke("rescan_library");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
@@ -252,6 +346,15 @@ export const rescanLibrary = () => invoke("rescan_library");
export const importAudition = (username, rel, files, startIndex = 0) =>
invoke("import_audition", { username, rel, files, startIndex });
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
// Which of a found copy's files the pen does not (fully) hold. Transfers die
// with the app and the arrived-count dies with them; the files on disk are
// what is left to ask.
export const importAuditionMissing = (rel, files) =>
invoke("import_audition_missing", { rel, files });
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
// The pen is keyed by RECORD, so without this the dropped peer's files sit
// beside the replacement's until the session is cleared.
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
// Measured facts about one audio file (the version card's quality chips).
export const importProbeFile = (path) => invoke("import_probe_file", { path });
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
// How the import finder asks when it is not ALLOWED to ask.
//
// The Soulseek server drops a search outright when the query carries every
// word of one of its excluded phrases. Measured 2026-08-11 against the live
// server, with the record's own copies provably on the network:
//
// animal collective merriweather 578 peers · 2,664 FLAC files
// merriweather pavilion 683 peers
// post pavilion 712 peers
// merriweather post pavilion 0 peers <- every time
// pavilion merriweather post 0 peers <- order is irrelevant
// merriweather post pavilion flac 0 peers <- extra terms don't help
//
// The published exclusions behave identically ("village people" 0, "bryan
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
// this is that same mechanism — but the server publishes only SOME of its
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
// unpublished one like this album's title reaches the wire and is swallowed.
//
// Nothing here can make the server answer a refused query. What it CAN do is
// ask a shorter question the server does answer, and then apply the part we
// were not allowed to say to the answers, where no server is involved.
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
* token nothing will match. */
export function queryWords(s) {
return String(s ?? "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.split(/\s+/)
.filter(Boolean);
}
/** The record's name with its last word dropped — the shortest change that
* breaks the refused word-set while still naming the record.
*
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
* is "the dark side of the", which asks the network for three words that
* carry no meaning and narrow the result set for nothing.
*
* Returns "" when there is nothing left worth asking. A one-word name
* shortens to nothing at all, and a bare artist query asks the network for
* that artist's entire discography — the same thing `findQueries` refuses
* to do. */
export function shortenName(name) {
const w = queryWords(name);
if (w.length < 2) return "";
w.pop();
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
return w.join(" ");
}
/** Does this peer's path claim to BE the record we could not ask for? Every
* word of the full name has to appear in it.
*
* This is the half of the question the server was not allowed to answer, and
* it is what keeps the shortened query honest: "animal collective merriweather
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
* the venue is in the path of every bootleg taped there.
*
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
* a folder that misspells the record is refused rather than guessed at. That
* costs a few real copies whose owners cannot spell; accepting near-misses
* costs correctness, and there is no way to tell the two apart from here. */
export function pathHasTitle(path, name) {
const need = queryWords(name);
if (!need.length) return false;
const have = new Set(queryWords(path));
return need.every((w) => have.has(w));
}
+30 -2
View File
@@ -27,8 +27,36 @@
*
* 1. Deezer try-list, MusicBrainz fallback.
* 2. + the artist's own catalogue, ranked by title similarity — finds records
* whose folder name no release carries ("1989 Pauls Boutique"). */
export const IDENTIFY_ENGINE = 2;
* whose folder name no release carries ("1989 Pauls Boutique").
* 3. + the artist the TAGS name, asked when the folder's artist found nothing
* or the wrong record, and + a try-list that refuses to strip a title down
* to its catalogue number ("Band Of Gypsys (SICP 2643)" → "(SICP 2643)").
* 4. + the title without a trailing release-type word ("Willoughby's Beach EP"
* → "Willoughby's Beach"). Both providers require every word of the
* phrase, so the one appended word hid a record each of them holds.
* 5. + the FOLDER as a second witness, asked after the record's own claim.
* Tags carry typos the folder does not ("Nonagon Infiniry" on all nine
* files, in a correctly-spelled folder), and until now one wrong tag was
* the only thing any provider was ever asked about.
* 6. + the record's TRACK COUNT breaks the tie when one title names several
* MusicBrainz release groups ("Murder of the Universe" is a 21-track Album
* and a 1-track Single, both scored 100, in an unstable order), and a
* provider request that FAILED no longer caches as "nothing found".
* 7. The last resort stopped being a title search dressed as a comparison. It
* ranks the artist's WHOLE MusicBrainz discography — complete, where
* Deezer's listing omitted EPs entirely — and decides on every fact the
* scan has: wording, track count, year, the TRACKLIST, and the record's own
* ARTWORK hashed against the discography's covers. A name is one fact about
* a record and the one most often wrong; `Rock Bottom 7"` scores 0.667
* against the release actually called "Rock Bottom / Octopus", and 2 of 2
* on its songs.
*
* BUMP THIS AFTER THE RUST BUILD LANDS, never before. Vite hot-reloads this
* constant instantly while the capability it describes lives in
* `metadata::identify_titles`, so bumping it against a running old binary
* makes that binary sweep the negatives and re-stamp the record on screen as
* freshly looked at by code that learned nothing. */
export const IDENTIFY_ENGINE = 7;
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
* value seen, which keeps the result stable for a re-render. */
+108
View File
@@ -0,0 +1,108 @@
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
// editor. Never presented as finished timing.
//
// The method deliberately stays above the research doc's forbidden floor
// ("never estimate line positions by evenly distributing text across song
// duration"): lines are laid out only across MEASURED vocal-active segments,
// weighted by line length, and when the measurement shows nothing usable the
// generator refuses instead of guessing. Real generated timing (ASR anchors
// + forced alignment, per the research) is a later, model-gated feature.
//
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
/** Hysteresis segmentation of a 0..1 activity envelope.
* Returns [{ startMs, endMs }], merged and pruned. */
export function activeSegments(frames, frameMs, opts = {}) {
const on = opts.on ?? 0.32; // enter singing
const off = opts.off ?? 0.16; // leave singing…
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
const minSegMs = opts.minSegMs ?? 1200;
const mergeGapMs = opts.mergeGapMs ?? 700;
const segments = [];
let start = null;
let belowRun = 0;
for (let i = 0; i < frames.length; i++) {
const v = frames[i];
if (start == null) {
if (v >= on) {
start = i;
belowRun = 0;
}
} else if (v < off) {
belowRun += frameMs;
if (belowRun >= offRunMs) {
const end = i * frameMs - belowRun + frameMs;
segments.push({ startMs: start * frameMs, endMs: end });
start = null;
belowRun = 0;
}
} else {
belowRun = 0;
}
}
if (start != null) {
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
}
// Merge close neighbours, then drop slivers.
const merged = [];
for (const seg of segments) {
const last = merged[merged.length - 1];
if (last && seg.startMs - last.endMs <= mergeGapMs) {
last.endMs = seg.endMs;
} else {
merged.push({ ...seg });
}
}
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
}
/** Plain text → the lines worth timing (section labels dropped). */
export function lyricLinesOf(plainText) {
return String(plainText ?? "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0)
.filter((l) => !/^\[.*\]$/.test(l));
}
/** Lay `lines` across `segments` by character weight over the concatenated
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
* inside an active segment), or null when there is nothing to lay onto. */
export function distributeLines(lines, segments) {
if (!lines.length || !segments.length) return null;
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
if (activeTotal < 4000) return null;
const weight = (line) => Math.max(line.length, 8);
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
// virtual position (0..activeTotal) → real ms via the segment list.
const toReal = (virtual) => {
let left = virtual;
for (const seg of segments) {
const len = seg.endMs - seg.startMs;
if (left <= len) return seg.startMs + left;
left -= len;
}
return segments[segments.length - 1].endMs;
};
const out = [];
let cumulative = 0;
for (const line of lines) {
const virtual = (cumulative / totalWeight) * activeTotal;
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
cumulative += weight(line);
}
// Monotonic by construction, but rounding can tie neighbours; nudge.
for (let i = 1; i < out.length; i++) {
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
}
return out;
}
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
export function roughSync(plainText, frames, frameMs) {
const lines = lyricLinesOf(plainText);
const segments = activeSegments(frames ?? [], frameMs ?? 100);
return distributeLines(lines, segments);
}
Binary file not shown.
+69 -3
View File
@@ -227,6 +227,8 @@ export const importStatus = $state({
label: "", // applied: the "· 12 GB" tail; otherwise unused
skipped: 0, // applied: records nothing was written for — the toast must
// say so, because "Imported 0 records" on its own reads as a dead end
error: "", // error: what went wrong, in words. "The import hit a problem"
// is not a reason, and a silent failure is not even that.
});
/** A paused import's presence on the Library page: the Import button wears
@@ -267,6 +269,8 @@ export const ui = $state({
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
windowActive: true,
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
lyricsFull: false,
});
export function toggleNowPlaying() {
@@ -311,16 +315,49 @@ export const libraryStore = $state({
export const playlistStore = $state({
playlists: [],
loaded: false,
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
// mutated playlist (new rows → new mosaic) refetches while an untouched
// one costs nothing on re-render.
covers: {},
});
// revision the cached cover was composed against, and in-flight dedupe.
const playlistCoverRev = new Map();
const playlistCoverInflight = new Map();
export async function refreshPlaylists() {
try {
playlistStore.playlists = await api.listPlaylists();
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
} catch {
// backend not available
} finally {
playlistStore.loaded = true;
}
}
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
/// gradient fallback".
export async function playlistCoverFor(pl, maxEdge = 512) {
const id = pl.id;
const cached = playlistStore.covers[id];
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
const req = api
.playlistCover(id, maxEdge)
.then((url) => {
playlistStore.covers[id] = url ?? null;
playlistCoverRev.set(id, pl.revision);
return url ?? null;
})
.catch(() => null)
.finally(() => playlistCoverInflight.delete(id));
playlistCoverInflight.set(id, req);
return req;
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
@@ -3691,9 +3728,18 @@ export const RESOLVER_AUDIO_QUALITY = {
// Formats that decode progressively while the file is still downloading.
// MP4-family files (m4a/aac) put their index at the end and only play once
// complete, so they're save-only.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
//
// `opus` was here and must not be: this build has no Opus decoder, so the
// resolver would pick an Opus source to stream, download it, and fail at the
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
// see crates/audio-formats.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
// Lossless for LABELLING — what the file claims to be, which is a different
// question from whether we can decode it. `.ape` and `.wv` are genuinely
// lossless and genuinely unplayable here; a peer's file is still worth naming
// accurately in search results.
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
// User source-selection preferences, independent per mode. Loaded from
// settings at startup and refreshed by the Settings view after a commit;
@@ -5031,10 +5077,24 @@ export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? [];
}
// Mirrors crates/audio-formats: everything recognised as audio, playable or
// not. Kept in sync by `audio_format_support`, which the backend answers from
// the same table the scanner and share index use.
export const AUDIO_EXTS = new Set([
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
// Recognised but with no decoder in this build. Shown, searchable, shareable
// and downloadable — never handed to the player, which refuses them anyway.
export const UNPLAYABLE_EXTS = new Set([
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
]);
export const isPlayableExt = (ext) =>
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
export const downloads = $state({
// id -> download payload (latest update wins)
items: {},
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
title: t.title,
sub: subLabel,
artist: t.artist ?? null,
album: t.album ?? null,
duration: t.duration_secs ?? null,
art: t.art_path ?? artPath,
quality: localQuality(t),
// Which playlist OCCURRENCE this queue item came from, so the playlist
// page highlights exactly the row that is playing — a recording that
// appears twice must light up once. Null for album playback.
plItemId: t.plItemId ?? null,
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView,
};
}