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
35 changes: 22 additions & 13 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,35 @@ def _env_path(name: str, default: Path) -> Path:
return Path(raw).expanduser().resolve() if raw else default


def _detect_device() -> str:
"""Pick best available Torch device for Demucs. Override via
STEMDECK_DEMUCS_DEVICE env var ('cuda' | 'mps' | 'cpu'). Apple Silicon
silently falls back to CPU otherwise -- demucs's CLI default is
"cuda if available else cpu" and macOS has no CUDA, leaving the
integrated GPU idle and processing 3-5x slower than necessary."""
forced = os.environ.get("STEMDECK_DEMUCS_DEVICE", "").strip().lower()
if forced in ("cuda", "mps", "cpu"):
return forced
def available_torch_devices() -> list[str]:
"""Compute devices this machine can actually use, best-first. CPU is always
present; cuda/mps depend on the hardware + installed torch build. The
Settings UI uses this to disable options that aren't available/detected so
a user can't pick an impossible device."""
devices: list[str] = []
try:
import torch

if torch.cuda.is_available():
return "cuda"
devices.append("cuda")
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
return "mps"
devices.append("mps")
except ImportError:
pass
return "cpu"
devices.append("cpu")
return devices


def detect_torch_device() -> str:
"""Best available Torch device for Demucs by hardware probe: cuda > mps >
cpu. Apple Silicon needs the explicit MPS check -- demucs's CLI default is
"cuda if available else cpu" and macOS has no CUDA, leaving the integrated
GPU idle and processing 3-5x slower than necessary.

User-facing device selection lives in app.core.settings (demucs_device,
default "auto" -> this probe); the STEMDECK_DEMUCS_DEVICE env var seeds
that setting's default so env-based deployments keep working."""
return available_torch_devices()[0]


ROOT = Path(__file__).resolve().parent.parent.parent
Expand Down Expand Up @@ -67,7 +77,6 @@ def _detect_device() -> str:
FFMPEG_DIR / ("ffprobe.exe" if sys.platform.startswith("win") else "ffprobe"),
)
DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s"
DEMUCS_DEVICE = _detect_device()
MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default
JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default
MAX_PENDING_JOBS = max(1, min(50, _env_int("STEMDECK_MAX_PENDING_JOBS", 3)))
Expand Down
54 changes: 53 additions & 1 deletion app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- `allow_network` — whether StemDeck answers requests from other devices.
- `max_duration_sec` — longest track accepted for processing.
- `video_max_height` — max video resolution for MP4 export / YouTube pulls.
- `demucs_device` — compute device for separation: auto | cuda | mps | cpu.

Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
Expand All @@ -18,7 +19,13 @@
import os
import threading

from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT
from app.core.config import (
DATA_DIR,
MAX_DURATION_SEC,
VIDEO_MAX_HEIGHT,
available_torch_devices,
detect_torch_device,
)

_log = logging.getLogger("stemdeck.settings")

Expand Down Expand Up @@ -138,3 +145,48 @@ def set_port(value: int) -> int:
_ensure()["port"] = clamped
_save()
return clamped


# ── demucs_device ──
# Compute device for stem separation. "auto" (default) resolves to the best
# available device via a hardware probe at job time; "cuda"/"mps"/"cpu" force
# it. Read live per job (app/pipeline/separate.py), so changes apply to the
# NEXT separation without a restart. STEMDECK_DEMUCS_DEVICE seeds the default
# so existing env-based deployments keep their forced device.
_DEVICE_CHOICES = ("auto", "cuda", "mps", "cpu")


def _default_demucs_device() -> str:
env = os.environ.get("STEMDECK_DEMUCS_DEVICE", "").strip().lower()
return env if env in ("cuda", "mps", "cpu") else "auto"


def get_demucs_device_choice() -> str:
"""The persisted user choice ("auto" | "cuda" | "mps" | "cpu") -- what the
Settings UI displays, as opposed to what jobs run on (see below)."""
with _LOCK:
v = _ensure().get("demucs_device")
return v if isinstance(v, str) and v in _DEVICE_CHOICES else _default_demucs_device()


def get_demucs_device() -> str:
"""The device the next separation job will actually use: the forced choice,
or a fresh hardware probe when the choice is "auto"."""
choice = get_demucs_device_choice()
return detect_torch_device() if choice == "auto" else choice


def set_demucs_device(value: str) -> str:
"""Persist a device choice. Forcing "cuda"/"mps" verifies the device is
actually available first and raises ValueError if not -- rejecting the
write loudly beats persisting a device that would silently fall back or
crash the next job (the #247 lesson, applied to the server path)."""
choice = (value or "").strip().lower()
if choice not in _DEVICE_CHOICES:
raise ValueError("demucs_device must be one of: " + ", ".join(_DEVICE_CHOICES))
if choice in ("cuda", "mps") and choice not in available_torch_devices():
raise ValueError(f"{choice} is not available on this machine")
with _LOCK:
_ensure()["demucs_device"] = choice
_save()
return choice
24 changes: 21 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,24 @@

from app.api.router import router
from app.core.config import (
DEMUCS_DEVICE,
DEMUCS_MODEL,
FFMPEG_BIN,
JOBS_DIR,
STATIC_DIR,
available_torch_devices,
configure_portable_environment,
ensure_runtime_dirs,
)
from app.core.registry import restore as restore_registry
from app.core.settings import (
get_allow_network,
get_demucs_device,
get_demucs_device_choice,
get_max_duration_sec,
get_port,
get_video_max_height,
set_allow_network,
set_demucs_device,
set_max_duration_sec,
set_port,
set_video_max_height,
Expand All @@ -45,7 +48,9 @@
# logger.info(...) call across the app, including the analyze
# diagnostics ("chroma:", "key candidates:").
logging.getLogger("stemdeck").setLevel(logging.INFO)
logging.getLogger("stemdeck").info("demucs config: model=%s device=%s", DEMUCS_MODEL, DEMUCS_DEVICE)
logging.getLogger("stemdeck").info(
"demucs config: model=%s device=%s", DEMUCS_MODEL, get_demucs_device()
)

configure_portable_environment()

Expand Down Expand Up @@ -211,7 +216,7 @@ def health() -> dict[str, object]:
"version": app_version(),
"ffmpeg_configured": FFMPEG_BIN.is_file(),
"demucs_model": DEMUCS_MODEL,
"demucs_device": DEMUCS_DEVICE,
"demucs_device": get_demucs_device(),
}


Expand All @@ -233,6 +238,12 @@ def _settings_payload() -> dict[str, object]:
"max_duration_sec": get_max_duration_sec(),
"video_max_height": get_video_max_height(),
"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;
# available lets the UI gray out devices this machine can't use.
"demucs_device": get_demucs_device_choice(),
"demucs_device_resolved": get_demucs_device(),
"demucs_devices_available": available_torch_devices(),
}


Expand Down Expand Up @@ -266,6 +277,13 @@ async def update_settings(request: Request) -> dict[str, object]:
setter(int(body[key]))
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None
if "demucs_device" in body:
try:
set_demucs_device(str(body["demucs_device"]))
except ValueError as e:
# 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
return _settings_payload()


Expand Down
9 changes: 7 additions & 2 deletions app/pipeline/separate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
import time
from pathlib import Path

from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL
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

logger = logging.getLogger("stemdeck.pipeline")

Expand All @@ -24,14 +25,18 @@
def separate(job: Job, source: Path, job_dir: Path) -> Path:
_set(job, status="separating", progress=0.0, stage="Separating stems...")

# Read the device fresh per job (not a frozen import) so a Settings change
# applies to the next separation without a restart.
device = get_demucs_device()
logger.info("[%s] separating on device=%s", job.id, device)
cmd = [
sys.executable,
"-m",
"demucs",
"-n",
DEMUCS_MODEL,
"-d",
DEMUCS_DEVICE,
device,
"-o",
str(job_dir),
str(source),
Expand Down
Loading