Drops the pre-release suffix from the version scheme. Releases are now
plain 0.9.0 rather than 0.9.0-alpha.N: a leading 0. already means "no
stability promise" under semver, so the suffix restated it, and the
GitHub releases have been marked not-prerelease all along anyway, which
contradicted the tag.
The software is still alpha and the docs still say so. Only the version
string changed.
Also fixes the bug-report placeholder, which showed a version format
that will no longer exist.
No code changes are needed to drop the suffix: hatch-vcs derives the
version from the git tag, and Cargo.toml and tauri.conf.json carry 0.0.0
placeholders filled at build time.
save_audio_file did two things in one command: show the native picker,
then stream the file. The frontend awaited the whole thing, so the
button read "Exporting..." from the moment it was clicked, including the
entire time the dialog sat open. Nothing was being exported during that
phase, and a user who took a while choosing a folder was simply told
something untrue.
Split into pick_export_destination and download_to_path. The busy state
is now entered from a callback the download helpers fire when bytes
actually start moving, so the label describes the transfer alone.
The transfer takes a token, not a path. #338 suggested
download_to_path(url, path), but a path parameter would hand anything
running in the WebView the ability to write an arbitrary localhost URL
to an arbitrary location on disk -- the destination has until now only
ever come from the native dialog. Instead the picked PathBuf stays in
Rust and JS holds an opaque single-use token. The token does not need to
be unguessable: every live token maps to a path the user already
approved in a dialog, so a monotonic counter is enough and no new
dependency is needed. Unconsumed picks are capped so an export the user
abandons cannot accumulate.
save_audio_file stays as a thin wrapper over both halves for the lane
download links, which have no busy state to mislabel.
Cancelling gets simpler rather than just better labelled: no busy state
is ever entered, so there is none to unwind.
Removes downloadCurrentStems, which was exported but never called. It
was also the only _triggerDownload caller in a loop, which would have
meant one save dialog per stem.
Verified by reintroducing the defect (entering the busy state before the
dialog is answered): 3 of the 4 new tests fail. The suite also covers
cancellation, the guard against queueing a second export while the
picker is open, and that the transfer is addressed by token rather than
by path.
Closes#338
Implements #339. CI ran one check on static/js -- a syntax parse -- so any
behavioural regression shipped unnoticed until a user hit it. #335 is the
case in point: "Export All Stems" became permanently unclickable after a
single export, for every track, until the app restarted. It shipped in
alpha 15 and a user found it.
Setup is Playwright against the real backend. tests/e2e/serve.sh seeds a
throwaway jobs directory with one finished track and execs uvicorn
against it, with every data path redirected, so a run cannot read or
touch a developer's library. Only the separation pipeline is skipped;
the endpoints, the registry and the Range requests for stems are real.
Two details in the fixtures are load-bearing, both learned by getting
them wrong first:
- The sidebar renders from the library store, not /api/jobs. A job on
disk but absent from the store is invisible in the UI, and a test that
clicks nothing passes for the wrong reason.
- stubTauri installs a controllable window.__TAURI__ so the desktop code
path runs. This is the point of the exercise: #335 was invisible in a
browser, because there the synthetic <a>.click() closes the chip panel
before the busy state is applied and the bug hides. The stub also
leaves save_audio_file pending until the test settles it, so the busy
state machine is driven rather than raced.
Nine tests cover all four defects from #335 and #337: rows re-enabled
after an export in both host modes, a second export still working, the
busy state waiting on the save rather than a timer, failures surfacing
and recovering, and export errors not offering a "Try again" that sends
the user to the URL import field.
Verified by reintroducing each defect and watching the suite fail:
clearing only the visible rows on reset fails 3 tests (the panel is
closed by then, so a visibility-filtered clear clears nothing), the
fixed-timer reset fails 1, and the retry button fails 1.
MP4 format switching is only partly covered. The video format is hidden
unless the track has one, and a video fixture is its own piece of work,
so what is here pins that MP4 is not offered for audio-only tracks.
Beat grid and transport coverage remain open on #339.
Closes#339
Implements #342. StemDeck stays portable: extract the tarball, run
./StemDeck, and none of this is required. install.sh is there for people
who would rather launch from their applications menu.
It installs the package it sits in and never downloads anything, so the
version and the CPU/NVIDIA variant come from the package itself
(backend/static/version.json and the cpu-only marker) and cannot drift
from the build being installed. That also removes any need to verify a
second download.
Design notes, mostly things the reference installer in #342 got wrong:
- Install is atomic. The new copy goes to <target>.new and is verified
before the old one is moved aside, so a failure partway leaves the
working install untouched. Removing the old copy first is what made a
failed upgrade in that fork leave the machine with no StemDeck, no
launcher and no manifest recording where it had been.
- A failed copy cleans up its own staging directory rather than leaving
a package-sized partial on disk.
- Exec is quoted, so an install path containing a space still launches.
- Version comparison is semver-aware. sort -V ranks 0.8.0-alpha.17 above
0.8.0, which would tell every pre-release user they were current the
day a stable release shipped.
- Reading a missing manifest key yields empty rather than killing the
script, which under set -euo pipefail is what a grep|head|cut pipeline
does.
- Global installs put the launcher in /usr/share/applications and the
icon in /usr/share/pixmaps, so other users on the machine can see it.
- Installing from inside the destination is refused rather than moving
the running script out from under bash.
- Non-x86_64 machines get a clear refusal instead of a binary that
cannot run.
User data is never touched. Stems live in ~/Documents/StemDeck and the
runtime, models and logs in $XDG_DATA_HOME/stemdeck, both outside the
install directory. Legacy data/ from pre-migration builds is carried
across an upgrade, and uninstall refuses to delete it, leaving the
folder and saying why.
tests/linux/test_install_sh.sh runs the real installer against a
synthetic package in a throwaway HOME: 52 checks covering install,
upgrade, the failed-upgrade case, uninstall, corrupt manifests, paths
with spaces, legacy data, self-install, arch refusal and the semver
table. CI runs it on Linux with shellcheck and desktop-file-validate.
Closes#361
Prep for the optional Linux installer (#342). Carrying the icon and a
.desktop template inside the package is what lets the installer be
self-contained: no second download, and no asset URL that can drift from
the release being installed.
The Tauri icon is already square at 1024x1024, so it doubles as the
desktop icon with no separate artwork to keep in sync.
Exec= is quoted in the template. The freedesktop spec splits Exec on
whitespace, so the unquoted form used by the reference installer in #342
produces an entry that tries to run ".../My" when the user installs to
a path like ~/My Apps. It is invisible until someone picks a custom
directory, which is why it is pinned by a test.
Both variants pick this up: the CPU and NVIDIA packages run the same
script with CPU_ONLY toggled.
install.sh itself, and the README-LINUX.txt change documenting it, land
with #361 -- this commit deliberately ships nothing that references a
file which does not exist yet.
Closes#360
* fix(player): read past the first 1 KB when locating the WAV data chunk
The chunked engine parses WAV containers itself and asked only for bytes
0-1023 when looking for the `data` chunk. RIFF is a linked list, so
anything the writer puts in front of `data` -- a LIST/INFO block, a JUNK
chunk padded for sector alignment -- pushes it out of that window. The
parser returned null, the engine reported a duration of 0, and playback
was disabled. The track rendered normally and the header showed its real
length, so it looked like a GPU or renderer fault rather than a parse
failure (#343).
Walk the chunk table properly and widen the request when it runs past
what was fetched, capped at 1 MB and 5 attempts. The parser reports
"need more bytes" separately from "not a WAV", since only the caller
knows whether more bytes can be had.
Two further container cases fixed along the way:
- WAVE_FORMAT_EXTENSIBLE carries the real format code in its SubFormat
GUID. Without reading it, a float32 file parsed cleanly and then
decoded to silence.
- A `data` size of 0 or 0xffffffff, written by encoders that stream to a
non-seekable target and never patch the length, gave a duration of 0
or 24347 seconds respectively. Clamp to the real length reported in
Content-Range.
Adds tests/js/wav-header.test.mjs, which drives the real engine against
synthetic layouts through a Range-honouring fetch stub. Against the
pre-fix engine it reports 25/38, with JUNK 4096, LIST 2 KB, JUNK 300 KB
and both unpatched data sizes failing. CI runs it alongside node --check.
Closes#358
* fix(player): tell the user when a track's audio fails to load
A track whose stems could not be loaded left the studio looking normal
and said nothing. The only trace was a console warning, which in a
release desktop build has no reachable devtools, so the failure was
invisible to the user and undiagnosable from a bug report. Working out
why #343 could not play took a screenshot and a round trip for a hexdump.
Both engines now record why ready() resolved false and expose it via
getLoadError(), separating a stem that could not be fetched from one
that could not be parsed or decoded -- those send the user somewhere
completely different. The player puts that message in the error box
above the track header.
Playback errors reuse the import error box, so they are tagged: the
player retracts its own message when another track loads, without
wiping an import failure the user has not read yet. Nothing cleared
that box on track switch before.
The player also now retries with the full-decode engine when the
chunked one cannot read a container, under the same RAM ceiling the
missing-peaks swap uses. The browser's own decoder handles layouts the
hand-rolled parser may not, so this turns "playback disabled" into
"playback works" for the whole class of container problems behind #343.
Closes#359
* fix(player): reject sample formats the chunked engine cannot decode
_pcmToAudioBuffer only handles 16-bit PCM and 32-bit float, but the
header parser accepted any depth. A 24-bit or 32-bit-integer file
therefore measured correctly, reported ready, and then decoded to
nothing on every chunk.
That is worse than failing outright. An all-empty chunk result is
treated as a transient network failure and evicted from the cache, so
the scheduler retries it on the next animation frame, forever, with the
playhead pinned at zero and no message on screen. Measured against a
synthetic 24-bit file with playback running: 82 range requests in 700 ms
(~117/sec) versus 3 for a healthy file.
Reject those formats at parse time instead. The engine then reports a
readable reason and the player hands the file to the full-decode
engine, whose decoder handles 24-bit and integer PCM -- so these files
now play instead of hanging. Verified end to end with a real
ffmpeg-produced 24-bit stem: the chunked engine declines it, the
fallback picks it up, the transport advances, and playback issues no
further range requests.
Also covers WAVE_FORMAT_EXTENSIBLE float32, which is only accepted
because the real format code is read out of the SubFormat GUID; without
that it reads as 0xfffe and is now correctly rejected rather than
silently decoding to nothing.
The runtime archive is removed once its runtime is installed, and a version change sweeps stale downloads plus any unfinished runtime swap. Deliberately narrow: settings.json (which holds the stems location), config.json, runtime/, ffmpeg/ and models/ are left alone.
Closes#356.
Settings -> General gains a StemData location row: where extracted stems live, how much is there, and a native folder picker to change it. Changing it moves the existing library, since the registry lives in that folder and leaving it behind would strand it.
Desktop only -- Docker and Unraid get their storage from a mounted volume, and STEMDECK_JOBS_DIR still overrides everything.
Closes#354.
Imports run through an explicit serial queue: queue several tracks, a playlist, or a folder of files and keep using StemDeck while they extract. Adds a Queue view with per-job cancel and drag-to-reorder, and a restored queue waits for the user to start it.
Closes#344, #345, #346, #347, #348, #349, #351, #352, #353.
* feat(export): prefix exported stems with the song title
Stems exported as "bass.wav" or "vocals.wav" are ambiguous the moment they
leave the app. Dropping several songs' stems into one project folder makes
them indistinguishable and they overwrite each other.
Every stem the user receives is now named "<Song>_<stem>.<ext>":
- ZIP members, via a new prefix argument to _build_stems_zip
- Single-stem downloads, via the Content-Disposition filename
- Single-stem region trims, which keep both the song and the _region marker
- The MP3 variant of a stem
The server carries the name because Content-Disposition wins over an
<a download> attribute for same-origin requests, so setting the attribute
alone had no effect. The attribute is set too, as the fallback for any
response that does not send the header.
_safe_title is split into _title_slug, which returns "" for a title that
sanitizes to nothing, and _safe_title, which keeps the "stems" fallback for
the whole-archive filename. A per-file prefix has to be droppable, otherwise
an untitled job yields a leading underscore on every member. The slug is
restricted to [A-Za-z0-9_], so it stays safe as a ZIP member name.
Also fixes the desktop per-stem download, which routed through open_url and
handed the file to the OS handler: the stem opened in a browser or media
player and was never saved, so no filename applied at all. It now goes
through save_audio_file like every other export.
Closes#336
* fix(export): prefix the MP3 region stem download too
Missed in the previous commit: the trimmed-region branch of the MP3 stem
route still built a bare "{name}_region.mp3", so it was the one stem file
the user could receive without the song prefix.
Its ternary also had an unreachable branch. Only the trimmed case reaches
that line; the untrimmed one returns from the cached-file branch above.
* fix(export): name the mixdown after the song too
The mixdown endpoint hardcoded filename="mixdown.{ext}", so every song's
mix and every region export downloaded as "mixdown.wav". Exporting a few
songs into one folder produced mixdown.wav, mixdown(1).wav, mixdown(2).wav
-- the same collision #336 reports for stems.
Content-Disposition overrides the <a download> attribute, so the name the
frontend already built was discarded. Only desktop escaped it, because
save_audio_file uses the frontend's name rather than the header.
The video export was already doing this correctly, which left the mixdown
as the only export not named after its song.
Names mirror the frontend's: <Song>_exported_mix.<ext>, and <Song>_region.<ext>
when start/end trim to a loop region.
* fix(export): re-enable Export All Stems after an export
resetBusy() cleared aria-disabled from the mix row and re-derived the
region row via applyFormatState(), but never cleared the stems row.
flashBusy() sets the attribute on all three, so after any export the
"Export All Stems" item stayed at opacity 0.4 with pointer-events: none
for the rest of the session, across every song, until the app restarted.
Only reproducible in the desktop build. In a browser _triggerDownload()
appends an <a> and clicks it; that synthetic click bubbles to the
document handler and closes the chip panel before flashBusy() runs, so
actionItems() -- which filters on offsetParent -- returns empty and
nothing is disabled. Under Tauri invoke() returns without that click,
the panel is still open, and all three rows get disabled.
Clear all three unconditionally rather than through the
visibility-filtered actionItems(), so the set and clear paths stay
symmetric. applyFormatState() still runs last and re-derives the region
row's genuine "no loop selected" state.
Fixes#335
* fix(export): don't show "Exporting" when there is nothing to zip
downloadAllStemsZip() returns early when there is no current job or no
stems loaded, but the click handler called flashBusy() regardless, so the
button showed "Exporting..." for 1200 ms for a download that never
started.
Return false on both early exits, matching the contract downloadCurrentMix
and downloadCurrentVideo already use, and surface the same kind of
showError() the mix and region rows do.
* fix(export): track real export completion instead of a fixed timer
flashBusy() reset on a 1200 ms timer regardless of how long the export
actually took, so on a large stems zip the button reported done while
ffmpeg was still working, and a failed save reported success.
_triggerDownload() now returns the invoke() promise on desktop, where
save_audio_file resolves only after the body is streamed to a temp file
and renamed, and `true` in a browser, where an <a download> is owned by
the download manager and reports nothing back. flashBusy() waits on the
promise when there is one and falls back to the timer only when there is
genuinely no signal. A rejected invoke now surfaces the backend message
instead of silently looking like a success.
A 15-minute backstop and a generation token keep a promise that never
settles from leaving the menu disabled for the session, which is the
failure mode #335 was about.
* fix(export): dismiss export errors instead of offering "Try again"
showError() always rendered a "Try again" button that hid the error and
moved focus to the URL import field. That is the right action for an
import failure and the wrong one for an export failure, which has nothing
to do with importing a new track.
It went unnoticed because export failures never reached the error box
before: the busy state was a fixed timer that could not tell success from
failure, so a failed save looked exactly like a successful one.
showError() now takes { retry }, defaulting to the existing behaviour.
The export call sites pass retry:false and get a plain Dismiss that clears
the box without stealing focus.
* fix(settings): dismiss log-export errors instead of offering "Try again"
Same defect as the audio export rows: a failed log export is not fixed by
being sent to the URL import field.
Adds a click track locked to a per-track beat grid, an editor for correcting that grid, an opt-in to include the click in exports, and a Settings > Logs tab.
Detection uses beat_this (MIT code and weights) with librosa as an offline fallback, because librosa's 120 BPM tempo prior resolves a 180 BPM track to 90 and no confidence metric catches it. Click scheduling is locked to the engine's source time domain and measured at 0.000 ms error over 70 s of continuous playback.
Import: accept .ogg (Vorbis or Opus in Ogg) and .opus uploads. The
pipeline already transcodes every local upload to 16-bit/44.1 kHz WAV
via ffmpeg before Demucs, so only the extension allow-lists change:
the API gate, the web file picker/drop validation, and the mobile
accept list (which already advertised .ogg but got a server 422).
Export: add OGG (Vorbis VBR q6, ~192 kbps — the quality tier matching
the MP3 setting) to the mixdown, region, and stems-zip endpoints plus
the export format toggle in the player.
Tests: the unsupported-extension fixtures used .ogg and now use .aiff;
new upload tests for .ogg/.opus and an ffmpeg-gated OGG zip transcode
test asserting real OggS output.
Closes#330
Co-authored-by: Thales <>
The Linux NVIDIA package could not start its backend at all. Setup detected
the GPU and pip-installed torch==X+cuXXX with --no-deps, mirroring Windows.
But Linux CUDA wheels do not bundle the CUDA runtime -- they dlopen
libcublas/libcudnn/... out of the nvidia-* PyPI packages at import time, and
make-portable.sh strips exactly those packages to keep the tarball under
GitHub's 2 GiB asset cap. The result was a CUDA torch with no CUDA runtime:
ValueError: libcublas.so.*[0-9] not found in the system path
app/main.py imports torch at module scope, so this killed the backend
outright ("backend did not become healthy within 90 seconds") on every
launch, not just GPU work.
- install_cuda_torch now runs a second, dependency-resolving pip pass on
Linux only (cuda_wheel_needs_runtime_deps). Same specs and index, without
--no-deps/--ignore-installed, so pip sees torch as satisfied and installs
only the missing nvidia-* wheels. Windows keeps the single --no-deps swap:
its wheels carry the DLLs in torch/lib and pulling ~2.5 GB of nvidia-*
there would be pure waste. macOS is untouched (MPS path).
- Restore CPU torch when verify_cuda_torch fails. Recording torchDevice=cpu
was never enough -- an unloadable CUDA wheel stays on disk and keeps the
backend from importing torch at all. New reason
"cuda-verify-failed-cpu-restore-failed" when even that fails.
- Extract run_pip_install (PID tracking, 20 min timeout, stderr logging) so
both installs and the restore share one path.
- Add the missing "Linux tar.gz" option to the bug report template, as the
reporter noted.
Co-authored-by: Thales <>
Trivy fails CI on two advisories published after main's last run; neither
is related to any code change.
- yt-dlp 2026.6.9 -> 2026.7.4 (CVE-2026-55404, HIGH). The pyproject floor is
raised too, not just the lock: build/Dockerfile installs from the project
constraints and would otherwise keep resolving the vulnerable version.
- quinn-proto 0.11.14 -> 0.11.16 (GHSA-4w2j-m93h-cj5j, HIGH, remote memory
exhaustion). Transitive; cargo pulls rand 0.9 -> 0.10 in that subtree.
Verified: pytest 214 passed / 15 skipped, ruff check + format clean,
cargo test 15/15 on Linux.
Co-authored-by: Thales <>
The release dialog toggles its docker-pull block (server mode) and
Download button (desktop mode) with the bare `.hidden` class. But this
page only loads variables/waves/daw.css, and the only rule that hides via
`.hidden` is the scoped `.daw .hidden` -- the dialog lives outside `.daw`,
and base.css's global `.hidden` is not loaded here. So neither inner
element was ever hidden: desktop showed an empty "UPDATE YOUR SERVER"
block, and server showed a meaningless Download button.
Add explicit `.release-docker.hidden` / `.release-card .about-link.hidden`
rules (same approach as `.about-backdrop.hidden`) so desktop hides the
docker block and server hides the Download button.
* feat(desktop): release-notes modal with per-arch download link
Clicking the "New release available" notification card now opens a
settings-style modal showing the GitHub release notes (rendered from a
minimal, XSS-safe markdown subset) and a Download button that points at
the asset matching the running build.
- New Tauri command build_target returns os/arch/gpu so the frontend can
pick the exact release asset (macOS keys on arch; Windows/Linux add the
.NVIDIA infix for the CUDA variant). Falls back to a navigator OS guess
in web/server mode.
- renderReleaseNotes handles headings, bold, http(s) links, bullet lists,
fenced code blocks, and GitHub blockquote admonitions (the macOS
"IMPORTANT" block), escaping first and emitting only whitelisted tags.
- Modal reuses the About-dialog styling; the notification card is now
click-to-open (badge + card on launch, modal only on click).
* chore(unraid): pin template to 0.8.0-alpha.13
* feat(desktop): show docker-pull guidance in server mode
In server/Docker mode there is no Tauri, so a per-arch desktop download
is meaningless (the client browser's OS/arch has nothing to do with the
container, and updates are done by pulling a new image). The release
modal now detects server mode and replaces the Download button with the
`docker pull ghcr.io/stemdeckapp/stemdeck:<tag>` command plus a note that
Unraid users update via the Community Applications template.
Desktop mode is unchanged (per-arch download).
#318 force-installed the CUDA torch wheel into the NVIDIA package, which
pushed the release zip to 2456 MB -- over GitHub's 2 GiB release-asset cap
(2147483648 bytes) -- so the Windows NVIDIA asset upload failed on the
v0.8.0-alpha.12 release.
Bundling was the wrong approach: the desktop app already installs the CUDA
build on first run via ensure_torch_device (which picks the cuXXX index
matching the detected GPU's compute capability / driver). The NVIDIA
package is meant to ship base torch small (~419 MB zip) and pull CUDA on
first launch. #317 is the actual fix -- it makes that first-run install
trigger again after a CPU->NVIDIA build swap.
Restore the non-CpuOnly path to base torch and document why not to bundle,
so this isn't reintroduced.
Co-authored-by: Thales <>
Bump the Unraid Community Applications template to the 0.8.0-alpha.12
image tag. Merge only after the v0.8.0-alpha.12 release is published, as
the versioned image tag is created by the release build.
Co-authored-by: Thales <>
The non-CpuOnly path relied on `pip install .` yielding a CUDA torch, but
the Windows PyPI torch wheel is CPU-only (unlike Linux, whose default wheel
bundles CUDA). GPU support in the "NVIDIA" package was therefore an implicit
property of the build host's pip resolution and could silently regress to
CPU. Force-install the cu124 wheel from the PyTorch index so the NVIDIA
build is deterministically GPU-capable; the CPU path is likewise made
explicit so it downgrades a host that resolved a CUDA wheel.
Co-authored-by: Thales <>
A persisted "cpu-only-package" torch device reason was treated as a
permanently settled decision by the setup gate, so replacing the CPU
portable build with the NVIDIA build in the same data dir left the user
pinned to CPU -- setup skipped ensure_torch_device (and its #247
clear_stale_cpu_marker + GPU re-probe) entirely.
Invalidate the stale reason at probe time when the current install is no
longer the cpu-only package, so the device reads as unsettled and setup
re-runs GPU detection. Reuses the existing #247 self-heal path.
Co-authored-by: Thales <>
#313 gated the reset behind STEMDECK_DESKTOP=1, both server-side and in
the UI. Removing that restriction: it's covered by the same network_gate
middleware every other settings-mutating endpoint already relies on (host
machine always allowed, a LAN device only while network access is on) --
not a new class of risk this endpoint introduces on its own. The Danger
zone section in Settings -> General now always renders; the frontend's
Tauri-specific reset_user_data call stays conditional on window.__TAURI__
existing (desktop only, no equivalent needed in server mode since the
library index there already lives in localStorage, which the existing
localStorage.clear() call already covers).
Confirm dialog and row description now say explicitly that a reset on a
shared server affects everyone who uses it.
Co-authored-by: Thales <>
A user reported that old work sessions kept reappearing across fresh
package installs even after deleting "the data folder". Root cause:
the real persisted state lives in ~/Documents/StemDeck/ (job data +
registry.json, and separately user-data.json for the library index),
not the extracted package's own bundled data/ folder -- so deleting or
replacing the executable never touches it.
app/core/registry.py: reset_all(jobs_dir) clears the in-memory
registry and deletes every entry under jobs_dir (job dirs, the failed/
quarantine, registry.json itself).
app/main.py: POST /api/reset, gated server-side by STEMDECK_DESKTOP=1
(not just hidden in the UI -- wiping JOBS_DIR on a shared server would
delete every user's data, not just the caller's). 409s if a job is
actively running rather than corrupting it mid-separation.
desktop/src-tauri/src/main.rs: new reset_user_data command clears the
persistent library-index store (user-data.json) -- a separate store
from job data holding folders/tracks/per-job mixer state/trash, with
no fixed key list to enumerate individually. Verified via WSL cargo
clippy + cargo test (no local Rust build in CI).
static/js/catalog.js + daw.css: Settings -> General -> a desktop-only
"Danger zone" section with a type-to-confirm dialog (must type
"RESET"). On confirm: POST /api/reset, then reset_user_data, then
localStorage.clear(), then reload -- every in-memory JS structure
re-initializes from empty instead of trying to reconcile piecemeal.
Closes#312
Co-authored-by: Thales <>
* perf(api): cache mixdown renders (#290)
Identical mixdown params re-ran the full ffmpeg graph on every request.
On a shared server, repeat downloads of the same export (a common case)
burned CPU for a pure function of the inputs.
_stream_ffmpeg optionally tees yielded chunks to a per-request temp file
as it streams; a clean finish atomically renames it into place as the
cache entry and prunes the cache to a 20-file / 500 MB budget (oldest
first). Any failure or client disconnect removes the temp file instead
-- a render the client didn't get in full never becomes a cache hit for
the next request.
get_mixdown's cache key covers every render input (job_id, ext, stems,
gains, region, and the live export sample rate setting), computed after
the existing job/stem validation so a deleted or not-ready job still
404s the same way it always has instead of serving a stale entry. A hit
returns a FileResponse with no ffmpeg invocation at all.
Also: cache/ (CACHE_DIR's default under the repo root for source runs,
same pattern as jobs/) wasn't gitignored -- added it alongside jobs/.
* fix(api): silence bandit B324 on the cache-key sha1 (not a security use)
* address code-quality review: log prune failures, unify import style
- _prune_mixdown_cache: log a debug line instead of silently swallowing
a failed unlink, so a stuck cache entry leaves a trace.
- tests/test_stems_api.py: use "from app.api import stems as stems_mod"
consistently instead of mixing it with "import app.api.stems as ...".
---------
Co-authored-by: Thales <>
Replaces the fresh-subprocess-per-job model with a warm worker process
that loads the demucs model once and serves jobs one at a time over a
stdin/stderr protocol, reusing the same process across consecutive
successful jobs on the same device instead of paying spawn + import +
model-load + CUDA warmup on every single job.
Measured on an RTX 3080 (see #288's data): startup was 35-42% of the
separate stage for a fresh worker. With reuse, a warm second job drops
separate_startup from ~5s to ~0.6s and total job time from ~13.5s to
~6.7s -- roughly half, for every job after the first on a given device.
app/pipeline/demucs_worker.py: the worker script (run via
`python -m app.pipeline.demucs_worker <device>`). Calls the exact same
demucs library functions the CLI itself calls (load_track, apply_model,
save_audio, same default split/overlap/segment/clip/bit-depth) -- not a
reimplementation of the audio pipeline, just the same calls made
repeatedly on an already-loaded model instead of once per fresh
process. Verified bit-for-bit identical output against the old
subprocess-CLI path on a real track (with shifts=0, since demucs's own
apply_model applies a random time-shift internally whenever shifts>=1,
independent of this change -- both paths share that variance equally).
app/pipeline/separate.py: _run_demucs now reuses-or-spawns a worker via
_get_worker(device) instead of always spawning; dispatches one JSON
line per job and reads progress from stderr exactly as before (same
tqdm-driven "NN%" lines, same watchdog-stall detection). A worker is
torn down -- never reused for the next job -- after a cancel or any job
failure: GPU/CUDA state afterward isn't something we can vouch for, so
only the happy path keeps the process warm. A device change (Settings,
or the GPU->CPU fallback within one job) always gets a fresh worker.
app/main.py: kill the worker on clean app shutdown so it's never left
as an orphaned process.
Closes#309
Co-authored-by: Thales <>
Adds a "Standard" / "Best (2x slower)" separation quality setting,
following the demucs_device runtime-settings pattern exactly
(app/core/settings.py get/set + env seed, app/main.py payload + POST
handler with 422 on an invalid choice).
"Best" appends --shifts 2 to the demucs invocation: separation runs
twice 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; a CPU user who opts in accepts the wait
knowingly.
Settings UI: new select next to Compute device on the General tab,
wired the same way as the export sample rate / video height selects.
Co-authored-by: Thales <>
Records time from Popen to demucs's first progress line as
job.stage_timings["separate_startup"] -- process spawn + model load,
as opposed to actual separation work. Written to metadata.json and the
completion summary alongside the other stage timings (#293).
Measurement only: subprocess isolation (kill-on-cancel, crash
containment) is a design feature we keep. Once real numbers are in from
representative machines, #288 gets a decision comment -- keep the
subprocess-per-job model (expected, since startup should be 5-15s of a
1-15min stage) or open a follow-up if it's a meaningful fraction of
total separate time on GPU.
Co-authored-by: Thales <>
app/pipeline/audio_stats.py: new scan_stem() does one streamed pass over
a stem WAV via sf.blocks() -- [min, max] per bucket (waveform peaks) and
RMS (stem presence), both from the same blocks. Constant memory: a
block is a few MB even for a 20-minute stereo stem, vs. sf.read()'s full
in-memory load (~420 MB for the same file, done for up to 8 files
back-to-back right after Demucs has already stressed memory -- a
plausible contributor to OOM failures on memory-constrained machines).
collect.compute_stem_peaks now delegates to scan_stem and returns each
stem's RMS from the same pass; peaks.json's format and bucketing are
unchanged (floor-division chunking, matching the old implementation
bucket-for-bucket -- verified by a golden test comparing against the old
sf.read()-then-chunk reference).
runner._run_common now derives stem_presence from that RMS map (moved
out of analyze.compute_stem_presence, which is deleted along with its
separate ffmpeg-downmix decode of every stem) instead of decoding each
stem twice.
Known, accepted delta: presence RMS is now measured over the full
stem at full sample rate, vs. the old ffmpeg-downmixed mono decode
capped at the first 180s. On a real 220s track this shifted some
quiet-stem presence values by up to ~6 points (piano 1->6, other
12->19) -- larger than initially estimated, but a strict accuracy
improvement (whole track, not a 3-minute window), not a regression.
Closes#286Closes#287
Co-authored-by: Thales <>
Adds Job.version, bumped by _set() on every field write. The SSE stream
now compares versions instead of re-serializing + string-diffing on every
0.2s tick -- idle connections drop from a full to_state()+json.dumps per
tick to one int compare, eliminating ~1,000 serializations/s at the
200-connection cap.
Also closes#285 for real: if job.version changes while to_state() is
mid-call, the snapshot may mix pre- and post-write fields (a torn read).
The stream loop now detects that (version read before vs. after
serializing) and discards the snapshot instead of yielding it, retrying
immediately.
Already-terminal jobs (done/error/cancelled) now close the stream right
after the initial snapshot instead of idling.
Closes#289
Co-authored-by: Thales <>
Adds a Registry tab to Settings showing the persisted job registry
(registry.json) in a read-only viewer, so the on-disk state can be
inspected without leaving the app. Backed by a new read-only
GET /api/registry endpoint.
Closes#303
Co-authored-by: Thales <>
The desktop parent watchdog used os.kill(os.getpid(), SIGTERM) to stop
the backend, with a comment promising uvicorn's shutdown sequence would
run. On Windows that call is TerminateProcess -- a hard kill that
bypasses every cleanup path, so the promise only held on POSIX.
signal.raise_signal(SIGTERM) triggers the in-process Python-level
handler uvicorn installed, with identical semantics on both platforms.
Closes#282
Co-authored-by: Thales <>
persist() is called concurrently from the pipeline thread, API threads,
and the sweep loop, all sharing one temp path. Two writers could
collide, and on Windows os.replace over a file another writer holds
open raises an uncaught PermissionError. The write+replace now happens
under the existing lock with a unique temp name per call (the
_ensure_cached_mp3 pattern), best-effort like the settings store.
_recover_done_job required metadata.json, which is written after status
flips to done -- a crash in that window left a complete stems dir
permanently unrecoverable. Such dirs now recover with a placeholder
title, and a minimal metadata.json is written immediately so the next
restart takes the normal path (self-healing, not a lasting special
case). The stems-present requirement is unchanged.
Closes#281Closes#284
Co-authored-by: Thales <>
The pre-download metadata probe (duration check) ran outside the retry
loop: a transient network blip on that single request failed the whole
job immediately, even though the actual download had a 3-attempt
backoff. The probe and the download now share one retry policy
(_with_retries), with the same retriable/non-retriable classification,
cancel translation, and user-visible "retrying" stage message.
Every YoutubeDL instance (probe, audio download, video track) now sets
an explicit 30 s socket_timeout so a stalled TCP connection can never
hang a job indefinitely.
Closes#279
Co-authored-by: Thales <>
One MPS/CUDA failure (OOM, unsupported op, driver hiccup) killed the
whole job with "Audio processing failed" -- the Mac Mini report
verbatim, where the user needed a LaunchAgent env-var hack to force
CPU. The job now retries once on CPU and completes, slower but alive.
The fallback is loud, never silent (the #247 lesson applied to the
runtime path): the stage line reads "GPU failed -- retrying on CPU
(slower)..." while it runs, the WARNING log carries the classified
cause and full stderr tail, and gpu_fallback/compute_device persist to
job state and metadata. It fires even when the user forced cuda/mps in
Settings -- a dead job with no diagnostics is strictly worse than a
slow one that explains itself.
Mechanics: separate() is now the retry-policy layer over _run_demucs()
(one attempt: spawn, stream progress, stall watchdog, cancel
translation) with a _demucs_cmd() seam for tests. Partial output from
the failed GPU attempt is cleared before the CPU run so collect() can
never pick up half-written stems; progress resets to 0 since CPU
restarts from scratch. A cancel during the GPU attempt raises
JobCancelled without a pointless CPU retry. If CPU also fails, the
SeparationError carries both attempts' stderr tails for the quarantine.
Closes#276
Co-authored-by: Thales <>
fs::File::create zeroed the previous session's log on every launch. The
natural response to a crashed session is "restart and retry" -- which
destroyed exactly the evidence needed to diagnose the crash (the Mac
Mini "audio processing failed" report had nothing to paste because of
this).
backend.log now rotates to backend.log.1 / backend.log.2 (oldest
dropped) before the new session opens it. All renames are best-effort:
a locked file on Windows must never block launch -- worst case we
append to the old file, which still beats truncating it. The oldest
generation is removed first because Windows fs::rename fails when the
destination exists.
Closes#278
Co-authored-by: Thales <>
Streamed ffmpeg renders (mixdown export, region trims, stem MP3, video
mux) sent stderr to DEVNULL. When ffmpeg died mid-stream the client
received a truncated file with HTTP 200 already committed -- and no
trace of the failure existed anywhere, making "my export is broken"
reports unsolvable.
stderr is now drained into a bounded tail (mandatory anyway once it is
a pipe -- an undrained full pipe would deadlock ffmpeg) and logged at
WARNING with a per-endpoint context (job id, format, stems) when the
process exits non-zero. Kills we initiated on client disconnect are
expected and stay silent; EOF-then-nonzero is the failure signature,
since returncode stays None until wait() even for an exited child.
Closes#280
Co-authored-by: Thales <>
The error path destroyed all evidence: rmtree on failure threw away the
demucs stderr, the stage, and the device, leaving "Audio processing
failed" as the only artifact -- undebuggable after the fact.
- Failed jobs now move to jobs/failed/<id> with an error.txt recording
stage, device, model, classified cause, stage timings, and the demucs
stderr tail. Heavy payloads (source, stems, video) are stripped first
so quarantines stay KB-scale. Expired after 7 days by a new sweep that
runs even on persistent-library deployments (failure evidence is
diagnostics, not library content). The TTL sweep skips failed/.
- New app/pipeline/errors.py: SeparationError carries the stderr tail +
device out of separate(); classify_failure() maps failure text to
out-of-memory / unsupported-device / disk-full / bad-input / unknown.
The classified cause surfaces as Job.error_detail, shown in the studio
as a muted secondary line under the generic error message.
- Per-stage wall-clock timings (download/prepare, analyze, separate,
post) recorded on the job, written to metadata.json, included in
error.txt, and emitted as a one-line completion summary with the
compute device -- performance regressions and the CPU-vs-GPU question
are now answerable from logs.
Closes#277Closes#294Closes#293
Co-authored-by: Thales <>
Attach a RotatingFileHandler (LOGS_DIR/stemdeck.log, 5 MB x 3, timestamped)
to the stemdeck logger so server and Docker deployments keep an on-disk
trail -- until now LOGS_DIR existed but nothing ever wrote to it, and
stdout scrollback was the only record. Best-effort: a read-only FS
degrades to stdout-only logging instead of failing startup.
Level is now controllable: STEMDECK_LOG_LEVEL=DEBUG|INFO|WARNING, with
STEMDECK_DEBUG=1 as shorthand. This also un-deadens the analyze
diagnostics ("chroma:", "key candidates:") -- they are logger.debug
calls that could never emit under the previous hardcoded INFO level,
despite the comment claiming otherwise.
Also stop interpolating raw exception reprs into the user-visible
"Analysis skipped" stage message; the traceback is already in the log.
Closes#291Closes#292Closes#283
Co-authored-by: Thales <>
* 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.
* fix(settings): keep the dialog a uniform size across tabs
Pin the settings dialog to a fixed height and let every pane fill it
(flex:1), so switching between General / Network / Export no longer
resizes the dialog. The General pane scrolls within the fixed area.
Refs #271
* fix(desktop): NVIDIA build silently falling back to CPU (#247)
Three independent defects each land the NVIDIA build on CPU with no visible
error and no recovery path:
1. The cpu-only marker was trusted in the shared per-user data dir, not just
the app root. The CPU build wrote/migrated that marker there, so anyone who
ever ran the CPU build got the NVIDIA build permanently pinned to CPU --
GPU detection never even ran. is_cpu_only_package now checks the app root
only; a stale data-dir marker is auto-deleted and logged.
2. A CPU result from a transient failure (no GPU detected, CUDA verify
failed) was persisted the same as a real CPU-only package, and the setup
gate treated any truthy torchDevice as "done" -- one bad first run pinned
CPU forever. Device selection now persists a reason (torchDeviceReason),
and the setup gate only treats cuda/mps or a genuine cpu-only package as
settled; a failure-born CPU or a legacy install with no reason re-probes
the GPU on the next launch. Existing affected installs self-heal on
relaunch, no user action needed.
3. nvidia-smi discovery only checked System32 and PATH; some DCH driver
installs place it only under DriverStore\FileRepository\nv*\. Added that
scan (newest package wins) and raised the first probe's timeout to 30s for
Optimus laptops waking a sleeping dGPU. Every detection decision is now
logged to setup.log.
Also drops the Windows CPU-only portable package's data\cpu-only staging
(scripts/windows/make-portable.ps1), which was the source of the poisoned
marker.
5 new Rust unit tests cover marker precedence, the self-heal + log line, CPU
builds not churning their own marker, and the DriverStore newest-wins scan.
* feat(settings): compute device selector for the self-hosted server
Companion to the desktop #247 fix, for the server/Docker/Unraid path: device
selection was a frozen constant (DEMUCS_DEVICE, computed once at import), so
the only override was the STEMDECK_DEMUCS_DEVICE env var plus a restart --
invisible to Docker/Unraid users without container access.
- app/core/settings.py: demucs_device setting (auto | cuda | mps | cpu,
default auto = hardware probe). Forcing cuda/mps verifies availability
BEFORE persisting and rejects with a clear error otherwise -- never persist
a device that would silently fall back later (the #247 lesson applied
here). STEMDECK_DEMUCS_DEVICE seeds the default so existing env-based
deployments keep their forced device.
- app/core/config.py: _detect_device -> detect_torch_device (pure hardware
probe; env handling moved to the settings seed); DEMUCS_DEVICE constant
removed.
- app/pipeline/separate.py: reads the device fresh per job -- a Settings
change applies to the next separation, no restart.
- app/main.py: /api/settings gains demucs_device (choice) and
demucs_device_resolved (what jobs will run on); POST validates via the
setter (422 with the reason). Startup log and /api/health read live.
- static/js/catalog.js: "Compute device" select in Settings -> Advanced,
showing the resolved device; a rejected force surfaces the server's reason
via showError and reverts the select. Also aligns the port-input fallback
with the 8000 default from the earlier port unification.
- .docs/improvements/self-hosted-compute-device-setting.md: design doc.
5 new tests: auto-resolution, env seeding, verify-before-persist rejection,
unknown-choice rejection, and the API round trip incl. 422 paths.
* feat(settings): gray out compute devices this machine can't use
The Compute device dropdown now disables options that aren't available or
detected (Auto and CPU are always selectable; CUDA/MPS depend on the
hardware + torch build), labeling them "— not available" so it's clear why.
- config.py: available_torch_devices() returns the usable devices best-first;
detect_torch_device() is now its first element (no duplicated torch probe).
- settings.py: set_demucs_device verifies against membership in
available_torch_devices() rather than only the top pick.
- /api/settings: new demucs_devices_available list for the UI.
- catalog.js: disable + relabel unavailable <option>s on load and after each
change.
* fix(ui): settings scrollbar no longer overlaps right-aligned controls
The Advanced settings pane scrolls, and its scrollbar drew directly over the
right-aligned Port / Compute device controls. Reserve a scrollbar gutter
(padding-right + equal negative margin so it sits in the card's existing 12px
padding), keeping content aligned with the fixed header/footer. Surfaced once
the new Compute device row made the pane tall enough to scroll.
Publishing v0.8.0-alpha.7 (a draft prerelease -> published) never triggered
Docker Publish: CI/Linux/Windows/macOS releases all still listen for
`published` and fired correctly, but docker-publish.yml (changed in #259) only
listened for [prereleased, released] -- and `prereleased` did not fire for this
publish. Result: ghcr.io/stemdeckapp/stemdeck:0.8.0-alpha.7 was never pushed,
while the Unraid CA template already points at that (nonexistent) tag.
Fix: trigger on [published, released] -- published is the reliable trigger
every other release workflow already relies on; released is kept for promoting
an already-published prerelease to Latest via the release-label edit. The
:latest condition now also covers a plain non-prerelease `published` (not just
`released`), so a first-time stable release still gets :latest.
Also add a workflow_dispatch `version` input, so a specific tag can be
(re)pushed by hand to recover from a missed trigger without re-touching the
release (which would needlessly re-run the OS build workflows).