Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
64 changes: 47 additions & 17 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import json
import logging
import math
import os
import re
import shutil
import subprocess
import threading
import uuid
from pathlib import Path
from typing import Literal

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
Expand Down Expand Up @@ -396,6 +399,20 @@ async def start_vocal_split(job_id: str) -> Response:

_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
_SECTIONS_WRITE_LOCK = threading.Lock()


def _write_json_atomic(path: Path, data: dict) -> None:
"""Durably replace a JSON file without exposing a partial write."""
temp = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp"
try:
with temp.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(data, indent=2) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
finally:
temp.unlink(missing_ok=True)


class SectionItem(BaseModel):
Expand All @@ -404,6 +421,10 @@ class SectionItem(BaseModel):
start: float
end: float
color: str
kind: (
Literal["intro", "outro", "break", "bridge", "inst", "solo", "verse", "chorus", "part"]
| None
) = None

@field_validator("id")
@classmethod
Expand Down Expand Up @@ -432,8 +453,18 @@ def _check_time(cls, v: float) -> float:
return round(v, 3)


# Upper bound on a section list. normalize_sections and the timeline editor
# both refuse a section shorter than 0.5 s, so the longest track StemDeck
# accepts (3600 s) cannot legitimately carry more than 7200 of them; 10000
# leaves headroom while refusing a payload sent to stall the event loop.
# Without a bound here a 33 MB body held every other request for ~4 seconds,
# and needed no valid job to do it: the body is parsed before the handler runs
# and answers 404 (#481).
_MAX_SECTIONS = 10000


class SectionsBody(BaseModel):
sections: list[SectionItem]
sections: list[SectionItem] = Field(max_length=_MAX_SECTIONS)


@router.patch("/{job_id}/sections")
Expand All @@ -445,30 +476,29 @@ def update_sections(job_id: str, body: SectionsBody) -> dict:
if job is None:
raise HTTPException(status_code=404, detail="job not found")

validated = [s.model_dump() for s in body.sections]
job.sections = validated
validated = [s.model_dump(exclude_none=True) for s in body.sections]

job_dir = (JOBS_DIR / job_id).resolve()
if not job_dir.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="job not found")
meta_path = job_dir / "metadata.json"

meta: dict = {}
if meta_path.is_file():
with _SECTIONS_WRITE_LOCK:
meta: dict = {}
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
meta["sections"] = validated
try:
meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.exception("failed to write sections for %s: %s", job_id, exc)
raise HTTPException(status_code=500, detail="failed to save sections") from exc

registry_persist(JOBS_DIR)
if meta_path.is_file():
meta = json.loads(meta_path.read_text(encoding="utf-8"))
meta["sections"] = validated
meta["sections_source"] = "manual"
_write_json_atomic(meta_path, meta)
except (OSError, json.JSONDecodeError) as exc:
logger.exception("failed to write sections for %s: %s", job_id, exc)
raise HTTPException(status_code=500, detail="failed to save sections") from exc

_set(job, sections=validated, sections_source="manual")
registry_persist(JOBS_DIR)

return {"job_id": job_id, "sections": validated}
return {"job_id": job_id, "sections": validated, "sections_source": "manual"}


# Upper bound on an edited grid. A 20-minute track at 300 BPM is ~6000 beats;
Expand Down
28 changes: 24 additions & 4 deletions app/api/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,35 @@ def _mixdown_cache_key(
return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()


def _prune_mixdown_cache(cache_dir: Path) -> None:
def _prune_mixdown_cache(cache_dir: Path, keep: Path | None = None) -> None:
"""Evict oldest-first once either the file-count or total-size budget is
exceeded. Best-effort: a failed prune just means the cache grows past
budget until the next successful render, not a broken export."""
budget until the next successful render, not a broken export.

`keep` is never evicted, and exists because a render is added to the cache
and then pruned before it is served. A single render larger than the whole
budget put the directory over on its own, so the loop deleted it, newest
and only entry though it was, and the caller handed a path that no longer
existed to FileResponse. A 60-minute WAV crosses the 500 MB budget at about
49.5 minutes, well inside the 60 StemDeck accepts (#482).

Its size still counts toward the total, so an oversized entry evicts
everything else and then stops, leaving the cache one file over budget
until the next render clears it. That is the intended trade: a rendered
file the user is waiting on outranks the budget.
"""
try:
entries = sorted(
(p for p in cache_dir.iterdir() if p.is_file() and not p.name.startswith(".")),
(
p
for p in cache_dir.iterdir()
if p.is_file() and not p.name.startswith(".") and p != keep
),
key=lambda p: p.stat().st_mtime,
)
total = sum(p.stat().st_size for p in entries)
if keep is not None and keep.is_file():
total += keep.stat().st_size
except OSError:
return
while entries and (len(entries) > _MIXDOWN_CACHE_MAX_FILES or total > _MIXDOWN_CACHE_MAX_BYTES):
Expand Down Expand Up @@ -498,7 +517,8 @@ async def _render_to_file(

if cache_path is not None:
os.replace(tmp_path, cache_path)
_prune_mixdown_cache(cache_path.parent)
# Exempt from its own prune: this is the file about to be served.
_prune_mixdown_cache(cache_path.parent, keep=cache_path)
return cache_path
return tmp_path

Expand Down
26 changes: 26 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,32 @@ def js_solver_available() -> bool:
TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300)
TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120)
TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800)
# Automatic functional-section analysis. Inference stays on CPU because the
# persistent Demucs worker deliberately keeps its model resident on the chosen
# accelerator between jobs; loading a second model beside it would make VRAM
# use depend on GPU size and the preceding job. The ensemble name remains
# configurable for deployments evaluating a different compatible checkpoint.
SECTION_MODEL = os.environ.get("STEMDECK_SECTION_MODEL", "harmonix-all").strip() or "harmonix-all"
TIMEOUT_SECTIONS = max(60, _env_int("STEMDECK_TIMEOUT_SECTIONS", 30 * 60))
TIMEOUT_SECTIONS_STALL = max(30, _env_int("STEMDECK_TIMEOUT_SECTIONS_STALL", 120))
# Conservative evidence gates for the section refiner. The first real-song
# diagnostic found that the upstream decoder emitted 13 Come As You Are spans
# but our equal-label merge hid six boundaries. It also found a suppressed
# 0.059 activation inside the intro with only 0.18 embedding novelty. Requiring
# 0.05 activation and 0.35 novelty preserves strong independent evidence
# without turning each instrumental entrance into a new functional section.
SECTION_REFINEMENT_GRID_MIN_CONFIDENCE = 70
SECTION_REFINEMENT_BEAT_SNAP_SECONDS = 0.12
SECTION_REFINEMENT_MIN_ACTIVATION = 0.05
SECTION_REFINEMENT_MIN_NOVELTY = 0.35
SECTION_REFINEMENT_NOVELTY_WINDOW_SECONDS = 8.0
SECTION_REFINEMENT_MIN_SEGMENT_SECONDS = 6.0
# The test track's disputed 49.85-65.89 span had a 0.045 Verse/Chorus margin,
# while the surrounding accepted semantic spans were at least 0.10. A weak
# tie becomes neutral Part rather than a confidently wrong functional label.
SECTION_REFINEMENT_MIN_LABEL_MARGIN = 0.08
SECTION_REFINEMENT_RECURRENCE_SIMILARITY = 0.85
SECTION_REFINEMENT_RECURRENCE_LABEL_MARGIN = 0.25
# On-demand lead/backing vocal split (#275). UVR-MDX-NET Karaoke 2 is an
# officially-distributed UVR-project model (MIT + credit-to-UVR per the
# audio-separator README) -- the default. STEMDECK_KARAOKE_MODEL lets a
Expand Down
4 changes: 3 additions & 1 deletion app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class Job:
dynamic_range: float | None = None # peak_db - integrated LUFS (dB)
tempo_stability: int | None = None # 0-100, beat interval consistency
stem_presence: dict[str, int] | None = None # per-stem RMS 0-100
sections: list[dict] | None = None # [{id, name, start, end, color}]
sections: list[dict] | None = None # [{id, name, kind?, start, end, color}]
sections_source: Literal["automatic", "manual"] | None = None
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
# Subset of stems the user chose at submit. The pipeline produces all
Expand Down Expand Up @@ -130,6 +131,7 @@ def to_state(self) -> dict[str, Any]:
"tempo_stability": self.tempo_stability,
"stem_presence": self.stem_presence,
"sections": self.sections,
"sections_source": self.sections_source,
"tags": self.tags,
"stems": self.stems,
"selected_stems": self.selected_stems,
Expand Down
1 change: 1 addition & 0 deletions app/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ def _recover_done_job(job_dir: Path) -> Job | None:
tempo_stability=meta.get("tempo_stability"),
stem_presence=meta.get("stem_presence"),
sections=meta.get("sections"),
sections_source=meta.get("sections_source"),
tags=meta.get("tags"),
vocal_split=vocal_split,
)
Expand Down
24 changes: 24 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ def set_auto_delete_jobs(value: bool) -> bool:
return bool(value)


def _default_auto_sections() -> bool:
"""Automatic song-structure detection, off until the user asks for it.

The stage costs a CPU inference pass per job and produces suggestions
rather than ground truth, so nobody should pay for it without having
chosen to. STEMDECK_AUTO_SECTIONS=1 turns it on for a deployment that
wants it from first boot.
"""
return os.environ.get("STEMDECK_AUTO_SECTIONS", "").strip() == "1"


def get_auto_sections() -> bool:
with _LOCK:
v = _ensure().get("auto_sections")
return v if isinstance(v, bool) else _default_auto_sections()


def set_auto_sections(value: bool) -> bool:
with _LOCK:
_ensure()["auto_sections"] = bool(value)
_save()
return bool(value)


def _default_auto_delete_days() -> int:
"""Honour a STEMDECK_JOB_TTL_SECONDS somebody already tuned.

Expand Down
45 changes: 44 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles

from app.api.router import router
Expand Down Expand Up @@ -47,6 +47,7 @@
get_allow_network,
get_auto_delete_days,
get_auto_delete_jobs,
get_auto_sections,
get_cookies_file,
get_demucs_device,
get_demucs_device_choice,
Expand All @@ -60,6 +61,7 @@
set_allow_network,
set_auto_delete_days,
set_auto_delete_jobs,
set_auto_sections,
set_cookies_file,
set_demucs_device,
set_export_sample_rate,
Expand All @@ -79,6 +81,7 @@
validate_target,
)
from app.pipeline.collect import sweep_failed_jobs, sweep_old_jobs
from app.pipeline.sections import sweep_orphaned_workspaces as sweep_orphaned_section_workspaces

# Set the stemdeck logger level (Python's default root level of WARNING would
# silently drop every logger.info(...) call) and attach the rotating file log
Expand Down Expand Up @@ -195,6 +198,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# A restored queue can be dozens of tracks and hours of GPU, and the user
# may well have opened StemDeck to do something else entirely. They press
# Start (or simply import something new, which lifts the pause).
# Nothing is analyzing yet, so any section workspace still on disk belongs
# to a process that died mid-stage and is safe to remove (#483).
try:
await asyncio.to_thread(sweep_orphaned_section_workspaces, JOBS_DIR)
except Exception:
_log.exception("could not sweep orphaned section workspaces")

resumed = take_pending_resume()
if resumed:
jobqueue.pause()
Expand Down Expand Up @@ -324,6 +334,10 @@ def _settings_payload() -> dict[str, object]:
# rather than appearing empty on first click.
"auto_delete_jobs": get_auto_delete_jobs(),
"auto_delete_days": get_auto_delete_days(),
# Automatic song-structure detection. Costs a CPU inference pass per
# job and produces suggestions rather than ground truth, so the user
# decides whether to pay for it.
"auto_sections": get_auto_sections(),
"auto_delete_days_min": AUTO_DELETE_DAYS_MIN,
"auto_delete_days_max": AUTO_DELETE_DAYS_MAX,
"max_duration_sec": get_max_duration_sec(),
Expand Down Expand Up @@ -374,6 +388,8 @@ async def update_settings(request: Request) -> dict[str, object]:
set_allow_network(bool(body["allow_network"]))
if "auto_delete_jobs" in body:
set_auto_delete_jobs(bool(body["auto_delete_jobs"]))
if "auto_sections" in body:
set_auto_sections(bool(body["auto_sections"]))
for key, setter in (
("auto_delete_days", set_auto_delete_days),
("max_duration_sec", set_max_duration_sec),
Expand Down Expand Up @@ -822,6 +838,33 @@ def download_logs_zip() -> StreamingResponse:
# restarts -- updated HTML loads against stale modules and the form
# silently breaks. `must-revalidate` keeps 304s working (cheap) while
# guaranteeing the latest mtime is honored.
# The timeline editors post JSON that is bounded by its model, but a model
# bounds only what is *stored*. FastAPI reads and parses a request body before
# the handler runs, so an oversized payload holds the event loop no matter what
# the model says: 32 MB of sections stalled every other request, including a
# running job's progress stream, for five seconds, and needed no valid job to
# do it (#481). Content-Length is checked here because middleware is the last
# point that runs before the body is touched. Same shape as the upload
# pre-check in app/api/jobs.py.
#
# The ceiling is far above either editor's reach: 10000 sections with the
# longest name each is about 1.6 MB, and 20000 beats about 0.4 MB. Uploads are
# unaffected -- they are a different path with their own 400 MB limit.
_EDITOR_BODY_LIMIT = 4 * 1024 * 1024
_EDITOR_PATH_SUFFIXES = ("/sections", "/beats")


@app.middleware("http")
async def limit_editor_body_size(request: Request, call_next):
if request.method in ("PATCH", "POST", "PUT") and request.url.path.endswith(
_EDITOR_PATH_SUFFIXES
):
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > _EDITOR_BODY_LIMIT:
return JSONResponse({"detail": "request body too large"}, status_code=413)
return await call_next(request)


@app.middleware("http")
async def security_and_cache_headers(request: Request, call_next):
response = await call_next(request)
Expand Down
Loading
Loading