dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

+183
View File
@@ -1,10 +1,71 @@
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
let settings = $state(null);
let saving = $state(false);
// Lyric alignment model (Tier B word timing) — downloaded only from the
// explicit click below, per the preparation-worker consent charter.
let lyricModel = $state(null);
let modelBusy = $state(false);
let modelPct = $state(0);
let modelError = $state("");
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
// row below, shown only once the aligner model above exists.
let sepModel = $state(null);
let sepBusy = $state(false);
let sepPct = $state(0);
let sepError = $state("");
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
$effect(() => {
let unlisten = null;
let unlistenSep = null;
void import("@tauri-apps/api/event").then(({ listen }) => {
listen("lyrics:model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlisten = u));
listen("lyrics:sep-model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlistenSep = u));
});
return () => {
unlisten?.();
unlistenSep?.();
};
});
async function downloadModel() {
if (modelBusy) return;
modelBusy = true;
modelError = "";
modelPct = 0;
try {
await api.lyricModelDownload();
lyricModel = await api.lyricModelStatus();
} catch (e) {
modelError = String(e).slice(0, 160);
} finally {
modelBusy = false;
}
}
async function downloadSepModel() {
if (sepBusy) return;
sepBusy = true;
sepError = "";
sepPct = 0;
try {
await api.vocalSepDownload();
sepModel = await api.vocalSepStatus();
} catch (e) {
sepError = String(e).slice(0, 160);
} finally {
sepBusy = false;
}
}
let savedFlash = $state(false);
let saveError = $state("");
let refreshingArtists = $state(false);
@@ -44,6 +105,15 @@
}
}
async function setWordTiming(next) {
if (next !== settings.word_timing) {
await commit({ wordTiming: next });
// Applies live: surfaces stop/start rendering word markup
// reactively, and turning it on asks for the current track.
setWordFill(next);
}
}
async function chooseDownloadDir() {
const dir = await api.pickFolder();
if (dir) await commit({ downloadDir: dir });
@@ -250,6 +320,79 @@
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section>
<section>
<h2>Lyrics</h2>
<div class="row">
<div class="label">
<div class="name">Word-by-word fill <span class="exp-tag">Experimental</span></div>
<div class="hint">
Fills each word as it is sung, karaoke-style. Still experimental: timing
can drift on dense or echo-heavy songs. Off keeps the reliable
line-by-line sync everywhere and pauses the background word analysis.
</div>
</div>
<select
class="device-select"
value={settings.word_timing ? "on" : "off"}
disabled={saving}
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
>
<option value="off">Off (recommended for now)</option>
<option value="on">On</option>
</select>
</div>
{#if settings.word_timing}
<div class="row">
<div class="label">
<div class="name">Precise word timing</div>
<div class="hint">
Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that
listens to each lyric line and times every word against the actual singing,
in most languages. Runs entirely on this computer, in the background, never
against the playing track. Without it, word timing stays a lighter estimate.
Already-timed songs re-prepare automatically the next time you open their
lyrics.
</div>
</div>
{#if lyricModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
</button>
{/if}
</div>
{#if modelError}
<p class="muted small">Download failed: {modelError}</p>
{/if}
{#if lyricModel?.present}
<div class="row">
<div class="label">
<div class="name">Vocal isolation</div>
<div class="hint">
Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from
the Ultimate Vocal Remover project) that lifts the singing away from
the band before words are timed — noticeably steadier timing on dense
mixes. Same rules as above: runs on this computer, in the background,
never against the playing track. Timed songs re-prepare the next time
you open their lyrics.
</div>
</div>
{#if sepModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
</button>
{/if}
</div>
{#if sepError}
<p class="muted small">Download failed: {sepError}</p>
{/if}
{/if}
{/if}
</section>
<section>
<h2>Streaming</h2>
<div class="row">
@@ -736,4 +879,44 @@
.save-error {
color: #f15e6c;
}
.model-btn {
flex: none;
padding: 8px 16px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.model-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.model-btn:disabled {
opacity: 0.7;
cursor: default;
}
.exp-tag {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
vertical-align: 2px;
}
.model-ok {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: #3ecf7e;
font-size: 13px;
font-weight: 700;
}
</style>