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
32 changes: 32 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- `video_max_height` — max video resolution for MP4 export / YouTube pulls.
- `export_sample_rate` — sample rate for exported mixes/regions (WAV/FLAC/MP3).
- `demucs_device` — compute device for separation: auto | cuda | mps | cpu.
- `separation_quality` — demucs shift-averaging: standard | best (2x slower).

Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
Expand Down Expand Up @@ -226,3 +227,34 @@ def set_demucs_device(value: str) -> str:
_ensure()["demucs_device"] = choice
_save()
return choice


# ── separation_quality ──
# "standard" (default) runs demucs once. "best" adds --shifts 2: demucs
# re-runs separation on a randomly time-shifted copy of the input and
# averages the two -- measurably cleaner stems, at ~2x the separation time.
# Applies on any device; a CPU user who picks "best" is accepting the wait
# knowingly. STEMDECK_SEPARATION_QUALITY seeds the default so existing
# env-based deployments can force it.
_QUALITY_CHOICES = ("standard", "best")


def _default_separation_quality() -> str:
env = os.environ.get("STEMDECK_SEPARATION_QUALITY", "").strip().lower()
return env if env in _QUALITY_CHOICES else "standard"


def get_separation_quality() -> str:
with _LOCK:
v = _ensure().get("separation_quality")
return v if isinstance(v, str) and v in _QUALITY_CHOICES else _default_separation_quality()


def set_separation_quality(value: str) -> str:
choice = (value or "").strip().lower()
if choice not in _QUALITY_CHOICES:
raise ValueError("separation_quality must be one of: " + ", ".join(_QUALITY_CHOICES))
with _LOCK:
_ensure()["separation_quality"] = choice
_save()
return choice
8 changes: 8 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@
get_export_sample_rate,
get_max_duration_sec,
get_port,
get_separation_quality,
get_video_max_height,
set_allow_network,
set_demucs_device,
set_export_sample_rate,
set_max_duration_sec,
set_port,
set_separation_quality,
set_video_max_height,
)
from app.pipeline.collect import sweep_failed_jobs, sweep_old_jobs
Expand Down Expand Up @@ -251,6 +253,7 @@ def _settings_payload() -> dict[str, object]:
"max_duration_sec": get_max_duration_sec(),
"video_max_height": get_video_max_height(),
"export_sample_rate": get_export_sample_rate(),
"separation_quality": get_separation_quality(),
"port": get_port(),
# The user's choice ("auto" | "cuda" | "mps" | "cpu") drives the UI
# select; the resolved value shows what jobs will actually run on;
Expand Down Expand Up @@ -307,6 +310,11 @@ async def update_settings(request: Request) -> dict[str, object]:
# set_demucs_device's messages are safe, user-actionable strings
# (invalid choice / device not available on this machine).
raise HTTPException(status_code=422, detail=str(e)) from None
if "separation_quality" in body:
try:
set_separation_quality(str(body["separation_quality"]))
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
return _settings_payload()


Expand Down
15 changes: 10 additions & 5 deletions app/pipeline/separate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app.core.config import DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL
from app.core.models import Job, JobCancelled, _set
from app.core.registry import set_proc
from app.core.settings import get_demucs_device
from app.core.settings import get_demucs_device, get_separation_quality
from app.pipeline.errors import SeparationError, classify_failure

logger = logging.getLogger("stemdeck.pipeline")
Expand All @@ -27,18 +27,23 @@
def _demucs_cmd(device: str, source: Path, job_dir: Path) -> list[str]:
"""Build the demucs CLI invocation. Module-level seam so tests can swap
in a stub executable without touching the process-management machinery."""
return [
cmd = [
sys.executable,
"-m",
"demucs",
"-n",
DEMUCS_MODEL,
"-d",
device,
"-o",
str(job_dir),
str(source),
]
# "best" quality (Settings -> General): demucs re-runs separation on a
# randomly time-shifted copy of the input and averages the two passes --
# measurably cleaner stems, ~2x the separation time. Applies on any
# device; read fresh per job like get_demucs_device() below.
if get_separation_quality() == "best":
cmd += ["--shifts", "2"]
cmd += ["-o", str(job_dir), str(source)]
return cmd


def _run_demucs(job: Job, source: Path, job_dir: Path, device: str) -> tuple[int, list[str]]:
Expand Down
19 changes: 18 additions & 1 deletion static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -1857,7 +1857,8 @@ async function wireGeneralSettings(overlay) {
const portInput = overlay.querySelector(".set-port");
const deviceSel = overlay.querySelector(".set-demucs-device");
const deviceResolved = overlay.querySelector(".set-demucs-resolved");
if (!durInput && !heightSel && !sampleRateSel && !portInput && !deviceSel) return;
const qualitySel = overlay.querySelector(".set-separation-quality");
if (!durInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel) return;

// Last server-confirmed device choice, to revert the select when the server
// rejects a forced device (e.g. CUDA not available on this machine).
Expand All @@ -1868,6 +1869,7 @@ async function wireGeneralSettings(overlay) {
if (heightSel && d.video_max_height) heightSel.value = String(d.video_max_height);
if (sampleRateSel && d.export_sample_rate) sampleRateSel.value = String(d.export_sample_rate);
if (portInput && d.port) portInput.value = String(d.port);
if (qualitySel && d.separation_quality) qualitySel.value = d.separation_quality;
if (deviceSel) {
// Gray out devices this machine can't use (Auto and CPU are always
// available). Label disabled options so it's clear WHY they're greyed.
Expand Down Expand Up @@ -1928,6 +1930,9 @@ async function wireGeneralSettings(overlay) {
const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8000));
post({ port });
});
qualitySel?.addEventListener("change", () => {
post({ separation_quality: qualitySel.value });
});
// Compute device needs its own POST path: unlike the clamped numeric
// settings, the server can REJECT a forced device (422 with a reason, e.g.
// "cuda is not available on this machine") -- surface that and revert.
Expand Down Expand Up @@ -2101,6 +2106,18 @@ function openLibraryEditor() {
</select>
</div>
</div>
<div class="settings-section">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">Separation quality</div>
<div class="settings-row-desc">Best runs the separator twice with randomized shifts and averages the result — cleaner stems, twice the time.</div>
</div>
<select class="settings-select set-separation-quality" aria-label="Separation quality">
<option value="standard">Standard</option>
<option value="best">Best (2× slower)</option>
</select>
</div>
</div>
<div class="settings-subhead">Out of sync tracks</div>
<div class="library-editor-table-wrap">
<table class="library-editor-table">
Expand Down
30 changes: 30 additions & 0 deletions tests/test_network_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,36 @@ def test_demucs_device_api_round_trip_and_422(monkeypatch, _isolated_settings):
assert c.post("/api/settings", json={"demucs_device": "bogus"}).status_code == 422


# ── separation_quality ──


def test_separation_quality_defaults_to_standard(_isolated_settings):
assert settings_mod.get_separation_quality() == "standard"


def test_separation_quality_env_seeds_default(monkeypatch, _isolated_settings):
monkeypatch.setenv("STEMDECK_SEPARATION_QUALITY", "best")
assert settings_mod.get_separation_quality() == "best"


def test_separation_quality_rejects_unknown_choice(_isolated_settings):
with pytest.raises(ValueError):
settings_mod.set_separation_quality("ultra")
assert settings_mod.get_separation_quality() == "standard" # nothing persisted


def test_separation_quality_api_round_trip_and_422(_isolated_settings):
with TestClient(app) as c:
assert c.get("/api/settings").json()["separation_quality"] == "standard"
r = c.post("/api/settings", json={"separation_quality": "best"})
assert r.status_code == 200
assert r.json()["separation_quality"] == "best"
assert c.get("/api/settings").json()["separation_quality"] == "best"
r = c.post("/api/settings", json={"separation_quality": "ultra"})
assert r.status_code == 422
assert c.get("/api/settings").json()["separation_quality"] == "best" # unchanged


def test_gate_blocks_non_loopback_when_off():
settings_mod.set_allow_network(False)
# TestClient's client host ("testclient") is treated as non-loopback.
Expand Down
13 changes: 13 additions & 0 deletions tests/test_separate_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ def test_gpu_failure_falls_back_to_cpu(job, tmp_path, monkeypatch, caplog):
assert "CUDA out of memory" in warning


def test_demucs_cmd_omits_shifts_at_standard_quality(monkeypatch, tmp_path):
monkeypatch.setattr(sep_mod, "get_separation_quality", lambda: "standard")
cmd = sep_mod._demucs_cmd("cpu", tmp_path / "source.wav", tmp_path)
assert "--shifts" not in cmd


def test_demucs_cmd_includes_shifts_2_at_best_quality(monkeypatch, tmp_path):
monkeypatch.setattr(sep_mod, "get_separation_quality", lambda: "best")
cmd = sep_mod._demucs_cmd("cpu", tmp_path / "source.wav", tmp_path)
i = cmd.index("--shifts")
assert cmd[i + 1] == "2"


def test_records_startup_timing_on_first_progress_line(job, tmp_path, monkeypatch):
"""#288: measurement only -- time from Popen to the first progress line
demucs emits, so the real subprocess/model-load startup cost can be
Expand Down