Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,133 @@ def _write_vocal_split_error(stems_dir: Path, cause: str, tail: list[str]) -> No
logger.warning("could not write vocal_split_error.txt in %s", stems_dir, exc_info=True)


class ResplitBody(BaseModel):
"""Which stems the new run should mix down to. Same shape the importer
takes, and bounded the same way: unknown names are dropped rather than
trusted, and an empty result falls back to every stem."""

stems: list[str] = Field(default_factory=list, max_length=len(STEM_NAMES))


def _retained_source(job_id: str) -> Path | None:
"""The upload kept beside a finished job, if there is one.

Only uploads have this. A link's source is deleted once its stems exist,
because it can be fetched again; an upload's cannot, so the runner keeps
it (see cleanup_source). Jobs finished before that change kept nothing,
which is why absence is an ordinary answer here rather than an error.
"""
job_dir = (JOBS_DIR / job_id).resolve()
if not job_dir.is_dir() or not job_dir.is_relative_to(JOBS_DIR.resolve()):
return None
for candidate in sorted(job_dir.glob("source.*")):
resolved = candidate.resolve()
if (
resolved.is_file()
and resolved.suffix.lower() in _ALLOWED_EXTS
and resolved.is_relative_to(JOBS_DIR.resolve())
):
return resolved
return None


def _resplit_source_url(source_url: str | None, new_id: str) -> str:
"""A source that names the same file without colliding with it.

The library replaces any existing track that shares a sourceUrl -- that is
how re-importing a link supersedes its old entry -- so a re-split reusing
the original's source deleted the very track it came from. Worse than
losing the row: the job directory stayed on disk with nothing referencing
it, and syncWithServer re-adopted it on the next launch as a duplicate.

Reusing the row is not an option either. Two jobs that share a source are
collapsed again on every launch, so the pair has to differ on disk, not
just in this session.

The marker goes before the extension, never after: deriveQuality reads the
suffix to tell a lossless WAV from a compressed MP3.
"""
base = source_url or "local:track"
head, dot, ext = base.rpartition(".")
marker = new_id[:6]
return f"{head} ({marker}){dot}{ext}" if dot else f"{base} ({marker})"


def _link_or_copy(src: Path, dest: Path) -> None:
"""Hard-link the source into the new job, copying only if that fails.

A re-split of a 300 MB upload should not cost another 300 MB. Both paths
are inside JOBS_DIR so a link is normally available; os.link raises
EXDEV across filesystems and EPERM on some mounts, and a copy is correct
in every case, just larger.
"""
try:
os.link(src, dest)
except OSError:
shutil.copy2(src, dest)


@router.post("/{job_id}/resplit")
async def resplit_job(job_id: str, body: ResplitBody) -> dict:
"""Separate a finished upload again, from the source kept beside it.

A link-sourced track has never needed this: re-importing its URL does the
same thing. An upload had no equivalent -- the file was handed over once,
and the composer has nothing to submit for it -- so changing which stems
you wanted meant finding the original file and uploading it a second time
(#635).

Produces a new job rather than mutating this one, which is exactly what
re-importing a link already does. The old track keeps its stems, its
sections and its beat grid until the person who asked for this decides
otherwise.
"""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not found")

source = _retained_source(job_id)
if source is None:
# Not a failure of this request: the track simply has nothing to
# re-separate from. The UI offers re-import for anything with a URL.
raise HTTPException(status_code=409, detail="no source kept for this track")

selected = [s for s in body.stems if s in STEM_NAMES] or list(STEM_NAMES)

new_id = uuid.uuid4().hex[:12]
new_dir = JOBS_DIR / new_id
new_dir.mkdir(parents=True, exist_ok=True)
try:
await asyncio.to_thread(_link_or_copy, source, new_dir / f"source{source.suffix}")
except OSError:
shutil.rmtree(new_dir, ignore_errors=True)
logger.exception("[%s] resplit could not stage the source", job_id)
raise HTTPException(status_code=500, detail="could not start re-split") from None

new_job = Job(
id=new_id,
selected_stems=selected,
title=job.title,
duration_sec=job.duration_sec,
# Still a local: source, so this job keeps its own copy and can be
# re-split in turn -- but distinct, so the library shows it beside the
# track it came from instead of replacing it.
source_url=_resplit_source_url(job.source_url, new_id),
auto_sections=get_auto_sections(),
)
if not registry_register_if_capacity(new_job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(new_dir, ignore_errors=True)
raise HTTPException(status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL)
jobqueue.enqueue(new_job.id)
registry_persist(JOBS_DIR)
# source_url comes back because the client cannot derive it: it is
# deliberately not the one it asked with, and the library keys its
# dedup on exactly this string.
return {"job_id": new_job.id, "source_url": new_job.source_url}


@router.post("/{job_id}/vocal-split")
async def start_vocal_split(job_id: str) -> Response:
"""Trigger the on-demand lead/backing vocal split (#275) for a completed
Expand Down
11 changes: 8 additions & 3 deletions app/pipeline/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ def _run_ffmpeg(job: Job, cmd: list[str]) -> bool:
def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]:
"""Move Demucs-emitted stems into the job's stems/ dir and clean up
the demucs intermediate dir. Does NOT delete the source download --
cleanup_source() is called by the runner after any post-processing
that needs to re-encode the source (e.g. building original.wav)."""
cleanup_source() is called by the runner, for the jobs whose source
can be fetched again, after any post-processing that needs to
re-encode it (e.g. building original.wav)."""
target_dir = job_dir / "stems"
target_dir.mkdir(exist_ok=True)
found: list[str] = []
Expand All @@ -94,7 +95,11 @@ def cleanup_source(job_dir: Path) -> None:
"""Delete the source audio file. Called after collect AND after any
post-processing that re-encodes the source (make_original_track).
The source is 100-300 MB, so getting rid of it is the bulk of disk
reclaim per job; only the stems remain."""
reclaim per job; only the stems remain.

Not called for uploads. The runner decides: a link can be fetched
again, an upload cannot, and deleting an upload's source destroys
the only copy StemDeck has of it."""
for f in job_dir.glob("source.*"):
f.unlink(missing_ok=True)

Expand Down
16 changes: 11 additions & 5 deletions app/pipeline/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from app.core.config import DEMUCS_MODEL, TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled, _set
from app.core.redact import redact
from app.core.registry import is_upload, set_proc
from app.core.registry import persist as persist_registry
from app.core.registry import set_proc
from app.pipeline.analyze import analyze
from app.pipeline.beatgrid import compute_beat_grid
from app.pipeline.collect import (
Expand Down Expand Up @@ -197,10 +197,16 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
mark = _lap(job, "separate", mark)
found = collect(job, stems_root, job_dir)
stems_dir = job_dir / "stems"
# Source (100-300 MB or the local upload) is no longer needed after
# collect; delete it before the ffmpeg amix steps in case scratch space
# is tight.
cleanup_source(job_dir)
# Deleting the source is the bulk of disk reclaim per job, and it happens
# before the ffmpeg amix steps below in case scratch space is tight. But
# only a job that can fetch its source again is allowed to give it up.
#
# An upload is the one copy StemDeck will ever have. Throwing it away means
# the track can never be separated again -- not with a different model, not
# at all -- and the person who imported it may no longer have the file
# either. A link costs a re-download; an upload costs the recording.
if not is_upload(job):
cleanup_source(job_dir)
job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found]
_check_cancel(job)
_set(job, stage="Mixing tracks...")
Expand Down
9 changes: 9 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ input, textarea { font-family: inherit; }
overflow: hidden;
}
.file-pill.hidden { display: none !important; }
/* The loaded-track pill reports state rather than staging input, so it reads a
step quieter than the file pill it borrows its shape from. */
.track-pill { opacity: 0.82; }
/* A staged file is what the button will act on next, so it speaks for itself
and the loaded track stands down. Keyed off the same has-file class the file
pill uses, so clearing the file brings this back without any JS agreeing to
it. */
.url-wrap.has-file .track-pill { display: none !important; }
.track-pill .file-name { max-width: 220px; }
.file-name { overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.file-size { color: var(--muted); flex-shrink: 0; }
.file-clear {
Expand Down
12 changes: 12 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@
<span class="file-size" id="fileSize"></span>
<button class="file-clear" id="fileClear" type="button" aria-label="Remove file" data-i18n-aria-label="topbar.removeFile">×</button>
</div>
<!-- The open track, when it is one the button acts on directly.
A YouTube track shows its URL in the box because that URL is
what gets submitted. An upload has nothing submittable to put
there, but it still needs to say which audio the button will
split, so it says it here instead of in the input. No clear
button: this reports what is loaded, it does not stage it. -->
<div class="file-pill track-pill hidden" id="trackPill">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<span class="file-name" id="trackPillName"></span>
</div>
<button class="daw-upload-btn" type="button" title="Upload audio file" id="uploadFileBtn" aria-label="Upload audio file" data-i18n-title="topbar.uploadFile" data-i18n-aria-label="topbar.uploadFile">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
Expand Down
67 changes: 61 additions & 6 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
queueCount, queueRowStates, reorderQueuedJob, runningLabel, startQueue,
startQueueStream,
} from "./queue.js";
import { fmtTime, storeGet, storeSet } from "./utils.js";
import { fmtTime, isReimportableSource, storeGet, storeSet } from "./utils.js";
import { notifyFailure, setReleasePending, dismissFailuresByJobId, dismissFailuresByKind } from "./notifications.js";
// Aliased (not the bare "t") -- this file already uses "t"/"tr" as local
// variable names for track objects and table rows in several scopes.
Expand Down Expand Up @@ -696,7 +696,7 @@ function applyStoredStemSelection(track) {
// only path back is a fresh re-upload.
function reimportUnavailableTrack(trackId, track) {
updateTrackStatus(trackId, "unavailable");
if (track.sourceUrl && !track.sourceUrl.startsWith("local:")) {
if (isReimportableSource(track.sourceUrl)) {
importFromUrl(track.sourceUrl, { title: track.title, stems: track.selectedStems });
return;
}
Expand Down Expand Up @@ -759,18 +759,73 @@ async function loadTrackIntoStudio(trackId) {
applyStoredStemSelection(track);
setCurrentTrack(trackId);

// The composer is an input the Split stems button submits, not a caption for
// the open track, so it may only ever hold something that can actually be
// imported. A `local:` source is a file that was uploaded once and is not
// reachable again; putting its bare filename here armed the button with a
// string that can never resolve, and pressing it POSTed "my song.mp3" as
// though it were a link (#635). Non-empty is also what let it through the
// browser's own required check.
//
// Cleared rather than left alone: whatever the previously opened track put
// there is still a live URL, and re-importing *that* on a click meant for
// this track is worse than doing nothing.
const importable = isReimportableSource(track.sourceUrl);
// A link-sourced track needs nothing new: its URL goes in the box and the
// button imports it again, which is what re-splitting one has always meant.
// An upload has no URL, so the button acts on the track itself and the
// server separates the source it kept beside it.
const resplittable = !importable && track.status === "done";
const urlInput = document.getElementById("url");
if (urlInput && track.sourceUrl) {
urlInput.value = track.sourceUrl.startsWith("local:")
? track.sourceUrl.slice(6)
: track.sourceUrl;
if (urlInput) {
urlInput.value = importable ? track.sourceUrl : "";
// `required` is the guard for "the button has nothing to act on", and it
// is a blunt one: the browser refuses the submit before any handler runs,
// so it must not be set while the button has a track to re-split. Setting
// it there made pressing Split stems on an upload answer "please fill out
// this field" instead of separating it.
if (importable || resplittable) urlInput.removeAttribute("required");
else urlInput.setAttribute("required", "");
}
setResplitTarget(resplittable ? track : null);

applyTrackInfoToPanel(track);
wireUpAudio(trackId, track.audioStems, track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise, track.hasVideo ?? false, track.videoStatus ?? null);
initSections(trackId, track.sections, track.duration || 0);
}

/**
* Aim the Split stems button at a track rather than at the composer.
*
* Carried on the button itself rather than in a module variable so job.js can
* read it at submit time without the two files having to agree on an import
* order. Cleared with null whenever the open track is one the composer can
* handle on its own.
*
* The button's label does not change. What it does is the same thing it has
* always done -- split this track into stems -- and only where it reads the
* track from differs.
*/
export function setResplitTarget(track) {
const submitBtn = document.getElementById("submit");
if (!submitBtn) return;
submitBtn.dataset.resplitJob = track?.id ?? "";
submitBtn.dataset.resplitTitle = track?.title ?? "";
submitBtn.dataset.resplitSource = track?.sourceUrl ?? "";

// Say which audio the button will split. A link-sourced track answers that
// with the URL in the box; an upload has nothing submittable to put there,
// so without this the composer sat empty and there was no way to tell what
// pressing the button would act on.
const pill = document.getElementById("trackPill");
const pillName = document.getElementById("trackPillName");
if (pill && pillName) {
pillName.textContent = track?.title ?? "";
pillName.title = track?.title ?? "";
pill.classList.toggle("hidden", !track);
}
}

export function setCurrentTrack(trackId) {
_currentTrackId = trackId;
for (const el of document.querySelectorAll(".cat-item.active")) el.classList.remove("active");
Expand Down
Loading
Loading