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
16 changes: 15 additions & 1 deletion app/api/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions 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.
- `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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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"])
Expand All @@ -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"]))
Expand Down
25 changes: 15 additions & 10 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,9 @@ input, textarea { font-family: inherit; }
}
.library-editor {
width: min(560px, calc(100vw - 32px));
max-height: min(70vh, 620px);
/* Fixed height (not max-height) so the dialog stays one size across all tabs
-- each pane fills it via flex:1, so switching tabs never resizes it. */
height: min(70vh, 540px);
display: flex; flex-direction: column;
border: 1px solid var(--border-strong);
border-radius: 10px;
Expand Down Expand Up @@ -777,21 +779,23 @@ input, textarea { font-family: inherit; }
.settings-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 5px 12px 9px; cursor: pointer; margin-bottom: -1px; }
.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"] {
/* Every pane fills the fixed dialog body so all tabs are the same size; the
General pane (the tall one, with the tracks table) scrolls within it. */
.settings-pane { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.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; }
Expand All @@ -808,6 +812,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; }
Expand Down
86 changes: 58 additions & 28 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -1834,6 +1834,7 @@ function networkSettingsHtml() {
<div class="settings-row-text">
<div class="settings-row-title">Make StemDeck available on your network</div>
<div class="settings-row-desc">Let other devices (like your phone) open StemDeck at the address below.</div>
<div class="settings-row-desc settings-lock-note">Read-only when StemDeck is started in server mode — network access is then set by your server configuration.</div>
</div>
<label class="settings-switch">
<input type="checkbox" class="net-access-input" />
Expand All @@ -1852,10 +1853,11 @@ function networkSettingsHtml() {
async function wireGeneralSettings(overlay) {
const durInput = overlay.querySelector(".set-max-duration");
const heightSel = overlay.querySelector(".set-video-height");
const sampleRateSel = overlay.querySelector(".set-export-samplerate");
const portInput = overlay.querySelector(".set-port");
const deviceSel = overlay.querySelector(".set-demucs-device");
const deviceResolved = overlay.querySelector(".set-demucs-resolved");
if (!durInput && !heightSel && !portInput && !deviceSel) return;
if (!durInput && !heightSel && !sampleRateSel && !portInput && !deviceSel) 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 @@ -1864,6 +1866,7 @@ async function wireGeneralSettings(overlay) {
const apply = (d) => {
if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60));
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 (deviceSel) {
// Gray out devices this machine can't use (Auto and CPU are always
Expand Down Expand Up @@ -1918,6 +1921,9 @@ async function wireGeneralSettings(overlay) {
heightSel?.addEventListener("change", () => {
post({ video_max_height: parseInt(heightSel.value, 10) });
});
sampleRateSel?.addEventListener("change", () => {
post({ export_sample_rate: parseInt(sampleRateSel.value, 10) });
});
portInput?.addEventListener("change", () => {
const port = Math.max(1024, Math.min(65535, parseInt(portInput.value, 10) || 8000));
post({ port });
Expand Down Expand Up @@ -1957,6 +1963,12 @@ async function wireNetworkSetting(overlay) {
const qrWrap = overlay.querySelector(".settings-net-qr");
if (!input) return;

// Server mode (no Tauri shell): network availability is governed by the server
// deployment, not this toggle. A headless server exists to be reached over the
// network, so present the switch as on and read-only (the "read-only in server
// mode" note explains how to change it via server config).
const serverMode = !window.__TAURI__?.core?.invoke;

let enabled = false;
let addresses = [];
try {
Expand All @@ -1967,6 +1979,7 @@ async function wireNetworkSetting(overlay) {
addresses = Array.isArray(data.lan_addresses) ? data.lan_addresses : [];
}
} catch { /* leave defaults */ }
if (serverMode) enabled = true;

// QR codes: one per LAN address, each encodes the /mobile/ URL so the
// phone camera opens StemDeck directly. Cards start blurred so an open
Expand Down Expand Up @@ -2048,7 +2061,8 @@ function openLibraryEditor() {
</div>
<div class="settings-tabs" role="tablist">
<button class="settings-tab active" type="button" data-tab="general" role="tab">General</button>
<button class="settings-tab" type="button" data-tab="advanced" role="tab">Advanced</button>
<button class="settings-tab" type="button" data-tab="network" role="tab">Network</button>
<button class="settings-tab" type="button" data-tab="export" role="tab">Export</button>
</div>
<div class="settings-pane" data-pane="general">
<div class="settings-section">
Expand All @@ -2063,19 +2077,30 @@ function openLibraryEditor() {
<div class="settings-section">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">MP4 video quality</div>
<div class="settings-row-desc">Max resolution for MP4 export and YouTube video.</div>
<div class="settings-row-title">Compute device</div>
<div class="settings-row-desc">Device used for stem separation. Applies to the next track<span class="set-demucs-resolved"></span>.</div>
</div>
<select class="settings-select set-video-height">
<option value="360">360p</option>
<option value="480">480p</option>
<option value="720">720p</option>
<option value="1080">1080p</option>
<select class="settings-select set-demucs-device" aria-label="Compute device">
<option value="auto">Auto</option>
<option value="cuda">CUDA (NVIDIA)</option>
<option value="mps">MPS (Apple Silicon)</option>
<option value="cpu">CPU</option>
</select>
</div>
</div>
<div class="settings-subhead">Out of sync tracks</div>
<div class="library-editor-table-wrap">
<table class="library-editor-table">
<thead><tr><th>Name</th><th>Source</th><th>Location</th></tr></thead>
<tbody class="library-editor-body"></tbody>
</table>
</div>
<div class="library-editor-foot">
<span class="library-editor-status" aria-live="polite"></span>
<button class="library-editor-sync" type="button">Resync out of sync tracks</button>
</div>
</div>
<div class="settings-pane hidden" data-pane="advanced">
<div class="settings-pane hidden" data-pane="network">
${networkSettingsHtml()}
<div class="settings-section">
<div class="settings-row">
Expand All @@ -2086,30 +2111,35 @@ function openLibraryEditor() {
<input type="text" class="settings-num-input set-port" inputmode="numeric" maxlength="5" aria-label="Port" />
</div>
</div>
</div>
<div class="settings-pane hidden" data-pane="export">
<div class="settings-section">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">Compute device</div>
<div class="settings-row-desc">Device used for stem separation. Applies to the next track<span class="set-demucs-resolved"></span>.</div>
<div class="settings-row-title">Sample rate</div>
<div class="settings-row-desc">Sample rate for exported mixes and regions (WAV, FLAC, MP3). 44.1 kHz suits most DAWs and samplers; pick another if your hardware needs it.</div>
</div>
<select class="settings-select set-demucs-device" aria-label="Compute device">
<option value="auto">Auto</option>
<option value="cuda">CUDA (NVIDIA)</option>
<option value="mps">MPS (Apple Silicon)</option>
<option value="cpu">CPU</option>
<select class="settings-select set-export-samplerate" aria-label="Export sample rate">
<option value="22050">22.05 kHz</option>
<option value="32000">32 kHz</option>
<option value="44100">44.1 kHz</option>
<option value="48000">48 kHz</option>
</select>
</div>
</div>
<div class="settings-subhead">Out of sync tracks</div>
<div class="library-editor-table-wrap">
<table class="library-editor-table">
<thead><tr><th>Name</th><th>Source</th><th>Location</th></tr></thead>
<tbody class="library-editor-body"></tbody>
</table>
</div>
<div class="library-editor-foot">
<span class="library-editor-status" aria-live="polite"></span>
<button class="library-editor-sync" type="button">Resync out of sync tracks</button>
<div class="settings-section">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">MP4 video quality</div>
<div class="settings-row-desc">Max resolution for MP4 export and YouTube video.</div>
</div>
<select class="settings-select set-video-height">
<option value="360">360p</option>
<option value="480">480p</option>
<option value="720">720p</option>
<option value="1080">1080p</option>
</select>
</div>
</div>
</div>
<div class="settings-foot">
Expand Down Expand Up @@ -2150,7 +2180,7 @@ function openLibraryEditor() {
const note = document.createElement("p");
note.className = "settings-server-note";
note.textContent = "These settings are read-only in server mode. To change them, update your server configuration (e.g. docker-compose.yml) and restart.";
overlay.querySelector("[data-pane='advanced']")?.prepend(note);
overlay.querySelector("[data-pane='network']")?.prepend(note);
}
}

Expand Down
18 changes: 18 additions & 0 deletions tests/test_network_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ def test_settings_reject_non_integer():
assert c.post("/api/settings", json={"max_duration_sec": "abc"}).status_code == 422


def test_export_sample_rate_round_trip_and_default(_isolated_settings):
assert settings_mod.get_export_sample_rate() == 44100 # default = stem rate
with TestClient(app) as c:
r = c.post("/api/settings", json={"export_sample_rate": 48000})
assert r.status_code == 200
assert r.json()["export_sample_rate"] == 48000
assert c.get("/api/settings").json()["export_sample_rate"] == 48000


def test_export_sample_rate_rejects_off_allowlist(_isolated_settings):
# An arbitrary rate is rejected (422), not clamped to the nearest allowed one.
with TestClient(app) as c:
assert c.post("/api/settings", json={"export_sample_rate": 96000}).status_code == 422
assert c.post("/api/settings", json={"export_sample_rate": "abc"}).status_code == 422
with pytest.raises(ValueError):
settings_mod.set_export_sample_rate(96000)


# ── demucs_device (compute device) ──


Expand Down
Loading