232 Commits

Author SHA1 Message Date
Tha.Les 3fe7c80730 chore(release): prepare 0.9.0 (#367)
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.
v0.9.0
2026-08-12 21:16:56 +01:00
Tha.Les 2f817990af fix(export): stop claiming to export while the save dialog is open (#366)
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
2026-08-12 20:43:28 +01:00
Tha.Les 1e1cf0bbce test(frontend): add browser tests, starting with the export menu (#365)
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
2026-08-12 20:18:48 +01:00
Tha.Les 5565b216ba feat(linux): add an optional installer for desktop integration (#364)
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
2026-08-12 19:08:49 +01:00
Tha.Les f9d7182f4b build(linux): stage desktop-entry assets into the portable tarball (#363)
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
2026-08-12 18:54:15 +01:00
Tha.Les 8d670e3b69 fix(player): read the whole WAV header, and say so when a track cannot load (#362)
* 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.
2026-08-12 18:53:50 +01:00
Tha.Les 2f123ee973 fix(desktop): stop hoarding installed runtime archives (#357)
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.
2026-08-12 13:53:44 +01:00
Tha.Les 1e8610bbc5 feat(settings): choose where extracted stems are stored (#355)
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.
2026-08-12 11:25:06 +01:00
Tha.Les afe871ce81 feat: background import queue, playlist import, and queue management (#350)
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.
2026-08-11 21:31:29 +01:00
Tha.Les a70e9cd53e chore(unraid): pin template to 0.8.0-alpha.17 (#341) v0.8.0-alpha.17 2026-08-09 08:49:21 +01:00
Tha.Les 41bd89d060 feat(export): name every export after its song (#340)
* 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.
2026-08-09 08:43:18 +01:00
Tha.Les b610275796 fix(export): re-enable Export All Stems after an export (#337)
* 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.
2026-08-09 08:01:29 +01:00
Tha.Les 30788531b2 feat: click track with beat grid detection and editor (#334)
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.
v0.8.0-alpha.16
2026-08-08 21:45:19 +01:00
Thales c68b7897b7 chore(unraid): pin template to 0.8.0-alpha.15 v0.8.0-alpha.15 2026-08-05 13:01:00 +01:00
Tha.Les c0e4f72169 feat: OGG and Opus support — import upload and OGG export (#331)
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 <>
2026-08-05 12:56:47 +01:00
dependabot[bot] de4199500b chore(deps): bump docker/login-action from 4.5.1 to 4.6.0 (#329)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:21:23 +01:00
dependabot[bot] e1a5ae3889 chore(deps): bump docker/login-action from 4.4.0 to 4.5.1 (#327)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.5.1.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:06:44 +01:00
dependabot[bot] a3ccc8c439 chore(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#328)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v7...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:06:11 +01:00
Tha.Les 655b0d0e95 fix(linux): install CUDA runtime deps with the GPU torch wheel (#324) (#325)
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 <>
v0.8.0-alpha.14
2026-07-26 00:19:59 +01:00
Tha.Les 387b5eb6ea chore(deps): patch yt-dlp and quinn-proto advisories (#326)
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 <>
2026-07-26 00:15:15 +01:00
dependabot[bot] 2e2ad8dd4f chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2 (#323)
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / js-syntax (push) Has been cancelled
CI / sast-bandit (push) Has been cancelled
CI / deps-audit (push) Has been cancelled
CI / trivy (push) Has been cancelled
Docker Publish / build-and-push (push) Has been cancelled
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:52:34 +01:00
Tha.Les 89717d204c fix(desktop): actually hide the mode-specific parts of the release modal (#322)
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.
v0.8.0-alpha.13
2026-07-18 18:06:01 +01:00
Tha.Les 739128d986 feat(desktop): release-notes modal with per-arch download link (#321)
* 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).
2026-07-18 17:32:18 +01:00
Tha.Les 8574be99a6 build(windows): don't bundle CUDA torch in the NVIDIA package (revert #318) (#320)
#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 <>
v0.8.0-alpha.12
2026-07-18 10:47:30 +01:00
Tha.Les cba8acc92f chore(unraid): pin template to 0.8.0-alpha.12 (#319)
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 <>
2026-07-18 01:15:03 +01:00
Tha.Les 9c3eedd2eb build(windows): install CUDA torch explicitly for the NVIDIA package (#316) (#318)
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 <>
2026-07-18 01:00:06 +01:00
Tha.Les 89367e5117 fix(desktop): re-detect GPU after CPU→NVIDIA build swap (#316) (#317)
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 <>
2026-07-18 00:59:49 +01:00
Tha.Les 5615fb063d chore(unraid): pin template to 0.8.0-alpha.11 (#315)
Co-authored-by: Thales <>
2026-07-17 16:51:39 +01:00
Tha.Les 0bec808ae1 feat(settings): make Reset app data available in server mode too (#314)
#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 <>
v0.8.0-alpha.11
2026-07-17 16:04:53 +01:00
Tha.Les 9e907030e6 feat(settings): add "Reset app data" (desktop, #312) (#313)
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 <>
2026-07-17 15:11:27 +01:00
Tha.Les 679eb78fa1 perf(api): cache mixdown renders (#311)
* 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 <>
2026-07-17 14:50:58 +01:00
Tha.Les 08b6abf9c1 feat(pipeline): persistent demucs worker (#309) (#310)
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 <>
2026-07-17 14:21:59 +01:00
Tha.Les 68c449db3c feat(settings): separation quality (--shifts) setting (#308)
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 <>
2026-07-17 12:19:33 +01:00
Tha.Les 5e8caeb73d measure(pipeline): record demucs startup cost per attempt (#307)
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 <>
2026-07-17 12:10:30 +01:00
Tha.Les 6eb1741362 perf(pipeline): single-pass streamed peaks + presence (#306)
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 #286
Closes #287

Co-authored-by: Thales <>
2026-07-17 12:02:11 +01:00
Tha.Les c896b9cfae perf(events): SSE dirty-flag + tear-proof job serialization (#305)
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 <>
2026-07-17 11:53:31 +01:00
Tha.Les 0a1baa7aa6 feat(settings): add read-only Registry tab (#304)
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 <>
2026-07-17 11:35:30 +01:00
Tha.Les 1050789b8d fix(desktop): watchdog shutdown must not hard-kill on Windows (#302)
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 <>
v0.8.0-alpha.10
2026-07-17 01:46:40 +01:00
Tha.Les 666005e921 fix(registry): persist race on Windows; recover metadata-less done jobs (#301)
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 #281
Closes #284

Co-authored-by: Thales <>
2026-07-17 01:44:37 +01:00
Tha.Les 4a3ba0f92a fix(download): retry the metadata probe; set socket timeouts everywhere (#300)
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 <>
2026-07-17 01:42:02 +01:00
Tha.Les 5355a93e45 feat(pipeline): retry separation on CPU when a GPU attempt fails (#299)
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 <>
2026-07-17 01:37:19 +01:00
Tha.Les 60959e7ad7 fix(desktop): rotate backend.log instead of truncating it on launch (#298)
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 <>
2026-07-17 01:21:29 +01:00
Tha.Les a666b39497 fix(api): log ffmpeg stderr when a streamed render fails (#297)
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 <>
2026-07-17 01:20:59 +01:00
Tha.Les 378c64fbc4 feat(pipeline): quarantine failed jobs with evidence; classify causes; stage timings (#296)
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 #277
Closes #294
Closes #293

Co-authored-by: Thales <>
2026-07-17 01:18:24 +01:00
Tha.Les 995e402220 feat(logging): rotating file log + level control; stop leaking exceptions into the UI (#295)
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 #291
Closes #292
Closes #283

Co-authored-by: Thales <>
2026-07-16 23:45:29 +01:00
Tha.Les 3359ed070a feat(settings): export sample rate option + reorganize settings tabs (#270)
* 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
v0.8.0-alpha.9
2026-07-16 15:33:44 +01:00
Tha.Les 51df10c919 chore(unraid): pin template to 0.8.0-alpha.8 (#268) 2026-07-15 21:42:44 +01:00
Tha.Les 700bd28829 Remove Star History section from README
Removed the Star History section from the README.
v0.8.0-alpha.8
2026-07-15 21:05:26 +01:00
Tha.Les c19d67eb79 fix(desktop): NVIDIA build silently falling back to CPU (#247) (#267)
* 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.
2026-07-15 20:47:19 +01:00
Tha.Les abc09e4894 fix(ci): docker-publish never fires on prereleased -- restore published (#266)
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).
2026-07-13 22:00:40 +01:00