dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user