From 67e44039d858f5b5ad08c83f0eef10b3dad5049e Mon Sep 17 00:00:00 2001 From: Thales <> Date: Thu, 16 Jul 2026 12:49:02 +0100 Subject: [PATCH 1/2] feat(settings): export sample rate option + reorganize settings tabs Add a configurable export sample rate for mix/region downloads (WAV/FLAC/ MP3), addressing hardware samplers (e.g. Akai MPC) that reject 44.1 kHz. The rate is a runtime setting read live by the mixdown endpoint, applied via ffmpeg -ar; default 44.1 kHz (the stem rate) is a no-op. Reorganize the Settings dialog into General / Network / Export tabs: - General: max track length, compute device, out-of-sync tracks - Network: availability toggle + QR, Port (moved here) - Export: sample rate, MP4 video quality (moved here) Also: - Port field now shows the live serving port, not the stale saved preference (editing still saves the preference for next restart). - In server mode the network toggle renders on + read-only, with an inline note explaining it is governed by server configuration. --- app/api/stems.py | 16 ++++++- app/core/settings.py | 36 ++++++++++++++++ app/main.py | 14 ++++++- static/css/daw.css | 17 ++++---- static/js/catalog.js | 86 +++++++++++++++++++++++++------------- tests/test_network_gate.py | 18 ++++++++ tests/test_stems_api.py | 13 ++++++ 7 files changed, 162 insertions(+), 38 deletions(-) diff --git a/app/api/stems.py b/app/api/stems.py index 396baecd..2c799876 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -16,6 +16,7 @@ from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable from app.core.registry import get as registry_get +from app.core.settings import get_export_sample_rate logger = logging.getLogger("stemdeck.api") @@ -307,7 +308,20 @@ async def get_mixdown( else: out_label = "[a0]" codec = MIXDOWN_CODECS[ext] - cmd += ["-filter_complex", ";".join(filters), "-map", out_label, *post_seek, *codec, "pipe:1"] + # Resample to the user's chosen export rate (default 44.1 kHz = the stem rate, + # so a no-op unless changed). Applies to every audio container -- some hardware + # samplers reject anything but a specific rate. + rate = ["-ar", str(get_export_sample_rate())] + cmd += [ + "-filter_complex", + ";".join(filters), + "-map", + out_label, + *post_seek, + *codec, + *rate, + "pipe:1", + ] media_type = MIXDOWN_MEDIA_TYPES[ext] return StreamingResponse( diff --git a/app/core/settings.py b/app/core/settings.py index c5bac2ab..985734bb 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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. +- `export_sample_rate` — sample rate for exported mixes/regions (WAV/FLAC/MP3). - `demucs_device` — compute device for separation: auto | cuda | mps | cpu. Defaults fall back to the config.py constants (which honor their env vars), so @@ -39,6 +40,12 @@ _PORT_MIN, _PORT_MAX = 1024, 65535 DEFAULT_PORT = 8000 +# Sample rates offered for mix/region export. 44.1 kHz (the Demucs stem rate, so +# the default is a pass-through) covers most samplers and DAWs; the others cover +# hardware that demands a specific rate (e.g. an Akai MPC rejecting 48 kHz). +EXPORT_SAMPLE_RATES = (22050, 32000, 44100, 48000) +DEFAULT_EXPORT_SAMPLE_RATE = 44100 + def _default_allow_network() -> bool: # STEMDECK_ALLOW_NETWORK takes precedence when set explicitly. @@ -147,6 +154,35 @@ def set_port(value: int) -> int: return clamped +# ── export_sample_rate ── +# Sample rate (Hz) the mix/region export encodes at. Read live per request by the +# mixdown endpoint (app/api/stems.py), so a change applies to the next export +# without a restart. Restricted to a small allowlist rather than clamped: an +# arbitrary rate is more likely a mistake than an intent, and hardware samplers +# only accept specific rates. +def get_export_sample_rate() -> int: + with _LOCK: + v = _num(_ensure().get("export_sample_rate")) + return v if v in EXPORT_SAMPLE_RATES else DEFAULT_EXPORT_SAMPLE_RATE + + +def set_export_sample_rate(value: int) -> int: + """Persist an export sample rate. Rejects anything outside the allowlist with + ValueError (surfaced as a 422) rather than clamping to the nearest rate.""" + try: + rate = int(value) + except (TypeError, ValueError): + raise ValueError("export_sample_rate must be an integer") from None + if rate not in EXPORT_SAMPLE_RATES: + raise ValueError( + "export_sample_rate must be one of: " + ", ".join(map(str, EXPORT_SAMPLE_RATES)) + ) + with _LOCK: + _ensure()["export_sample_rate"] = rate + _save() + return rate + + # ── 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 diff --git a/app/main.py b/app/main.py index dcc5f7e8..e59432fd 100644 --- a/app/main.py +++ b/app/main.py @@ -32,11 +32,13 @@ get_allow_network, get_demucs_device, get_demucs_device_choice, + get_export_sample_rate, get_max_duration_sec, get_port, get_video_max_height, set_allow_network, set_demucs_device, + set_export_sample_rate, set_max_duration_sec, set_port, set_video_max_height, @@ -237,6 +239,7 @@ def _settings_payload() -> dict[str, object]: "allow_network": get_allow_network(), "max_duration_sec": get_max_duration_sec(), "video_max_height": get_video_max_height(), + "export_sample_rate": get_export_sample_rate(), "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; @@ -253,7 +256,10 @@ def get_settings(request: Request) -> dict[str, object]: # host). The port is whatever this request came in on. port = request.url.port or 8000 addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip)) - return {**_settings_payload(), "lan_addresses": addresses} + # Show the port the server is actually running on rather than the stored + # preference (which only takes effect on the next restart) -- so the field + # reflects reality. Editing it still saves the preference via POST. + return {**_settings_payload(), "port": port, "lan_addresses": addresses} @app.post("/api/settings", tags=["settings"]) @@ -277,6 +283,12 @@ 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 "export_sample_rate" in body: + try: + set_export_sample_rate(body["export_sample_rate"]) + except ValueError as e: + # Allowlist violation / non-integer -- the message names the valid rates. + raise HTTPException(status_code=422, detail=str(e)) from None if "demucs_device" in body: try: set_demucs_device(str(body["demucs_device"])) diff --git a/static/css/daw.css b/static/css/daw.css index 7740c9a6..916fdb4a 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -778,20 +778,20 @@ input, textarea { font-family: inherit; } .settings-tab:hover { color: var(--fg-2); } .settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); } .settings-pane { display: flex; flex-direction: column; min-height: 0; } -.settings-pane[data-pane="general"] { flex: 1; } -.settings-pane[data-pane="advanced"] { +.settings-pane[data-pane="general"] { flex: 1; overflow-y: auto; /* Reserve a gutter for the scrollbar so it never overlaps the right-aligned - controls (Port, Compute device). padding-right insets the content; the - equal negative margin lets that gutter sit in the card's own 12px padding, - so scrolling content stays aligned with the fixed header/tabs/footer. - Handles overlay scrollbars (macOS/WebKit, which draw over the padding box) - and reserves space for classic scrollbars (Windows/Linux). */ + controls (Compute device, Out of sync tracks). padding-right insets the + content; the equal negative margin lets that gutter sit in the card's own + 12px padding, so scrolling content stays aligned with the fixed + header/tabs/footer. Handles overlay scrollbars (macOS/WebKit, which draw + over the padding box) and reserves space for classic scrollbars + (Windows/Linux). */ scrollbar-gutter: stable; padding-right: 12px; margin-right: -12px; } .settings-pane.hidden { display: none; } -.settings-pane[data-pane="advanced"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; } +.settings-pane[data-pane="general"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; } .settings-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 28px 10px; } .settings-foot { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); } .settings-done { min-height: 32px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.35); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 0 22px; cursor: pointer; } @@ -808,6 +808,7 @@ input, textarea { font-family: inherit; } .settings-row-text { min-width: 0; } .settings-row-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } .settings-row-desc { font-size: 10.5px; color: var(--muted); margin-top: 3px; line-height: 1.45; } +.settings-lock-note { font-style: italic; opacity: 0.85; margin-top: 5px; } .settings-switch { position: relative; flex-shrink: 0; width: 42px; height: 24px; cursor: pointer; } .settings-switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } .settings-switch-track { position: absolute; inset: 0; border-radius: 999px; background: rgba(148,163,184,0.22); border: 1px solid var(--border-strong); transition: background 0.15s ease; } diff --git a/static/js/catalog.js b/static/js/catalog.js index 40be56c4..82249630 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1834,6 +1834,7 @@ function networkSettingsHtml() {