bf561a6c81
* Add on-demand lead/backing vocal split, fix stems relocation bugs, and eager model pre-download (#275, #403) Lead/backing vocal split: - New on-demand POST /api/jobs/{id}/vocal-split endpoint, running UVR-MDX-NET Karaoke 2 (audio-separator) as a second pass over Demucs's vocals.wav - Desktop and mobile UI toggle to request the split, auto-chained once the base separation finishes, for both foreground and background jobs - Mixer shows Lead Vocals / Backing Vocals lanes in place of Vocals once split Stems relocation fixes (#403): - user-data.json (library metadata) now lives inside the jobs folder so it follows a Settings relocation instead of staying behind in Documents - The relocation endpoint's settings persist step was silently swallowing write failures and reporting false success; it now reports persisted: false and the Settings UI shows a clear warning instead Desktop setup wizard: - Demucs, beat-this, and the karaoke model now download eagerly during first-boot setup instead of lazily on first use Also: - Credit audio-separator / Ultimate Vocal Remover in the README per its license's attribution requirement, plus a license audit in docs/models.md - Add models/ to .gitignore * ci: install build-essential so diffq (audio-separator's dependency) can compile diffq has no prebuilt wheel for Python 3.11+ on Linux, its last release only ever shipped cp310 wheels, so uv sync must compile it from source, which needs gcc. Docker and the Linux desktop release build already install build-essential for the same reason; the plain lint/test CI container never needed it before audio-separator (#275) pulled diffq in. * chore: pin Unraid template to 0.12.0 This PR ships as v0.12.0, per the user's decision given it introduces the new lead/backing vocal split feature. --------- Co-authored-by: Thales <>
223 lines
9.6 KiB
JavaScript
223 lines
9.6 KiB
JavaScript
import { $, storeGet, storeSet } from "./utils.js";
|
|
import { STEM_NAMES } from "./constants.js";
|
|
|
|
// ─── DOM refs ───
|
|
|
|
export const form = $("job-form");
|
|
export const urlInput = $("url");
|
|
export const submitBtn = $("submit");
|
|
|
|
export const playBtn = $("t-play");
|
|
export const playMiniBtn = $("t-play-mini");
|
|
export const stopBtn = $("t-stop");
|
|
export const loopBtn = $("t-loop");
|
|
export const titleEl = $("title");
|
|
export const bpmChip = $("t-bpm");
|
|
export const keyChip = $("t-key");
|
|
export const stemsChip = $("t-stems-chip");
|
|
export const timeEl = $("t-time");
|
|
export const masterFader = $("t-master");
|
|
export const speedBtns = ["t-speed-025", "t-speed-05", "t-speed-1"].map($);
|
|
export const npArt = $("np-art");
|
|
export const npThumb = $("np-thumb");
|
|
|
|
export const jobBox = $("job");
|
|
export const jobTitleEl = $("job-title");
|
|
export const jobStageEl = $("job-stage");
|
|
export const jobDetailEl = $("job-detail");
|
|
export const jobCancelBtn = $("job-cancel");
|
|
export const progressEl = $("progress");
|
|
|
|
export const errorEl = $("error");
|
|
export const lanesEl = $("lanes");
|
|
export const mixerEl = $("mixer");
|
|
export const multitrackContainer = $("multitrack-container");
|
|
export const wavesGrid = $("waves-grid");
|
|
export const rulerTime = $("ruler-time");
|
|
export const loopRegionEl = $("loop-region");
|
|
export const playheadMarker = document.querySelector(".playhead-marker");
|
|
export const waveScroll = $("wave-scroll");
|
|
export const waveCanvas = $("wave-canvas");
|
|
export const presenceRulerEl = $("presence-ruler");
|
|
export const presencePlayheadEl = $("presence-playhead");
|
|
export const footerTimeElapsed = $("footer-time-elapsed");
|
|
export const footerTimeTotal = $("footer-time-total");
|
|
export const footerWaveTicks = $("footer-wave-ticks");
|
|
export const loopStartInput = $("t-loop-start");
|
|
export const loopEndInput = $("t-loop-end");
|
|
export const metroBtn = $("t-metro");
|
|
export const metroPanel = $("t-metro-panel");
|
|
export const metroVolEl = $("t-metro-vol");
|
|
export const metroVolLabel = $("t-metro-vol-label");
|
|
export const metroBarEl = $("t-metro-bar");
|
|
export const metroNoteEl = $("t-metro-note");
|
|
export const metroHalfBtn = $("t-metro-half");
|
|
export const metroOneBtn = $("t-metro-one");
|
|
export const metroDoubleBtn = $("t-metro-double");
|
|
export const metroCountInEl = $("t-metro-countin");
|
|
export const metroEditBtn = $("t-metro-edit");
|
|
export const exportClickEl = $("t-export-click");
|
|
export const exportClickWrap = $("t-export-click-wrap");
|
|
export const exportCountInEl = $("t-export-count-in");
|
|
export const exportCountInWrap = $("t-export-count-in-wrap");
|
|
export const bgToolbar = $("beatgrid-toolbar");
|
|
export const bgCanvas = $("beatgrid-canvas");
|
|
export const bgUndoBtn = $("bg-undo");
|
|
export const bgRedoBtn = $("bg-redo");
|
|
export const bgResetBtn = $("bg-reset");
|
|
export const bgDoneBtn = $("bg-done");
|
|
export const bgRippleEl = $("bg-ripple");
|
|
export const bgSnapEl = $("bg-snap");
|
|
export const bgBarLenEl = $("bg-barlen");
|
|
export const bgHintEl = $("bg-hint");
|
|
export const stemListEl = document.querySelector(".stem-list");
|
|
export const npScrubEl = document.querySelector(".np-scrub");
|
|
export const npScrubFill = $("footer-scrub-fill");
|
|
export const footerTitle = $("footer-title");
|
|
export const footerMeta = $("footer-meta");
|
|
export const footerThumb = $("footer-thumb");
|
|
|
|
// ─── Mutable state ───
|
|
|
|
export let eventSource = null;
|
|
export let multitrack = null;
|
|
// Web Audio decode-and-mix engine (Safari-safe playback). Null = legacy streaming path.
|
|
export let audioEngine = null;
|
|
export let currentJobId = null;
|
|
// The import whose progress owns the #job box and the studio view. Distinct
|
|
// from currentJobId, which is the track loaded in the studio: with a queue the
|
|
// two come apart the moment a background import runs while the user browses
|
|
// something else. A background job must not repaint the studio, and opening
|
|
// another track must not break the running import's Cancel button.
|
|
export let foregroundJobId = null;
|
|
|
|
// `mixerState` is mutated in place (never reassigned). renderMixerRow's
|
|
// closures capture each entry by reference, so on a new job we merge
|
|
// localStorage values into the existing objects rather than replacing them.
|
|
export const mixerState = {};
|
|
|
|
export let trackIndex = {};
|
|
export let totalDuration = 0;
|
|
export let loopEnabled = false;
|
|
export let loopStart = 0;
|
|
export let loopEnd = 0;
|
|
|
|
// Selected stems for extraction. The set determines (a) which stem
|
|
// rows render in the studio dashboard after a job completes and (b)
|
|
// which stem audio gets loaded into the multitrack. Backend always
|
|
// runs Demucs on all 6 stems regardless -- filtering happens entirely
|
|
// client-side at render time. Persisted across reloads in localStorage
|
|
// so a user who turns off "Vocals" stays set up that way for the
|
|
// next song.
|
|
const _STEM_SEL_KEY = "stemdeck:selected-stems";
|
|
|
|
// Start with all stems selected (safe default). The async store load below
|
|
// updates this binding once the store is available; ES module live bindings
|
|
// ensure all importers see the updated value on next read.
|
|
export let selectedStems = new Set(STEM_NAMES);
|
|
|
|
// Resolves when the persisted stem selection has been loaded from the store.
|
|
// Consumers that need the exact stored selection (e.g. the stem-choice UI)
|
|
// should await this before reading selectedStems.
|
|
export const stemSelectionReady = (async () => {
|
|
try {
|
|
const arr = await storeGet(_STEM_SEL_KEY, null);
|
|
if (Array.isArray(arr) && arr.length > 0) {
|
|
const valid = arr.filter((n) => STEM_NAMES.includes(n));
|
|
if (valid.length > 0) {
|
|
selectedStems = new Set(valid);
|
|
return;
|
|
}
|
|
}
|
|
} catch (e) { console.warn("[state] failed to load stem selection:", e); }
|
|
// Keep the all-stems default.
|
|
})();
|
|
|
|
export function saveSelectedStems() {
|
|
storeSet(_STEM_SEL_KEY, [...selectedStems]).catch((e) =>
|
|
console.warn("[state] failed to save stem selection:", e)
|
|
);
|
|
}
|
|
export function setStemSelected(name, selected) {
|
|
if (selected) selectedStems.add(name);
|
|
else selectedStems.delete(name);
|
|
saveSelectedStems();
|
|
}
|
|
|
|
// On-demand lead/backing vocal split (#275): "all" (default, plain Vocals
|
|
// lane) or "split" (auto-run the split once the next import finishes).
|
|
// A page-level setting like selectedStems, applied at whatever moment the
|
|
// user submits -- not stored per-job.
|
|
const _VOCAL_SPLIT_MODE_KEY = "stemdeck:vocal-split-mode";
|
|
export let vocalSplitMode = "all";
|
|
export const vocalSplitModeReady = (async () => {
|
|
try {
|
|
const v = await storeGet(_VOCAL_SPLIT_MODE_KEY, null);
|
|
if (v === "split") vocalSplitMode = v;
|
|
} catch (e) { console.warn("[state] failed to load vocal split mode:", e); }
|
|
})();
|
|
export function setVocalSplitMode(mode) {
|
|
vocalSplitMode = mode === "split" ? "split" : "all";
|
|
storeSet(_VOCAL_SPLIT_MODE_KEY, vocalSplitMode).catch((e) =>
|
|
console.warn("[state] failed to save vocal split mode:", e)
|
|
);
|
|
}
|
|
|
|
// Web Audio analysers for live VU meters.
|
|
export let audioContext = null;
|
|
export let masterVolume = 0.5; // mirrored from masterFader.value
|
|
export const trackAnalysers = []; // index → { analyser, data, vuEl }
|
|
export let vuRafId = null;
|
|
|
|
// Master bus nodes — created once in audio.js, shared across mixer.js.
|
|
// masterBusGain is driven by the master fader; masterLimiter is a
|
|
// transparent brickwall limiter that prevents inter-stem summing clipping.
|
|
export let masterBusGain = null;
|
|
export let masterLimiter = null;
|
|
|
|
// ─── Setter helpers for mutable state (so other modules can update) ───
|
|
|
|
export function setEventSource(v) { eventSource = v; }
|
|
export function setMultitrack(v) { multitrack = v; }
|
|
export function setAudioEngine(v) { audioEngine = v; }
|
|
export function setCurrentJobId(v) { currentJobId = v; }
|
|
export function setForegroundJobId(v) { foregroundJobId = v; }
|
|
export function setTrackIndex(v) { trackIndex = v; }
|
|
export function setTotalDuration(v) { totalDuration = v; }
|
|
export function setLoopEnabled(v) { loopEnabled = v; }
|
|
export function setLoopStart(v) { loopStart = v; }
|
|
export function setLoopEnd(v) { loopEnd = v; }
|
|
export function setAudioContext(v) { audioContext = v; }
|
|
export function setMasterVolume(v) { masterVolume = v; }
|
|
export let playbackSpeed = 1.0;
|
|
export function setPlaybackSpeed(v) { playbackSpeed = v; }
|
|
export function setVuRafId(v) { vuRafId = v; }
|
|
export function setMasterBusGain(v) { masterBusGain = v; }
|
|
export function setMasterLimiter(v) { masterLimiter = v; }
|
|
|
|
// Footer waveform draw callback — set by player.js, called by transport.js
|
|
export let footerWaveDrawFn = null;
|
|
export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; }
|
|
|
|
// Click track. `metronome` is the scheduler bound to the current engine (null
|
|
// when the job has no beat grid or the streaming path is in use); the enabled
|
|
// flag and volume survive track switches so the user's choice sticks.
|
|
export let metronome = null;
|
|
export function setMetronome(v) { metronome = v; }
|
|
export let metronomeEnabled = false;
|
|
export function setMetronomeEnabled(v) { metronomeEnabled = v; }
|
|
export let metronomeVolume = 0.6;
|
|
export function setMetronomeVolume(v) { metronomeVolume = v; }
|
|
// -1 = follow the bar marks the detector found; 0 = no accent; N = accent
|
|
// every N beats from the top of the track.
|
|
export let metronomeBeatsPerBar = -1;
|
|
export function setMetronomeBeatsPerBar(v) { metronomeBeatsPerBar = v; }
|
|
// Whether the current track's grid carries detected bar marks at all. Without
|
|
// them "Auto" has nothing to follow and behaves as no accent.
|
|
export let metronomeHasBars = false;
|
|
export function setMetronomeHasBars(v) { metronomeHasBars = !!v; }
|
|
// Count me in on play: one bar of click before the audio (issue #269).
|
|
// Independent of the running click track above.
|
|
export let metronomeCountIn = false;
|
|
export function setMetronomeCountIn(v) { metronomeCountIn = !!v; }
|