34 Commits

Author SHA1 Message Date
Tha.Les b9437115ca Only offer an update once the release has been promoted (#429)
Two halves of the same bug.

The upload steps passed no `prerelease` to softprops/action-gh-release,
which defaults it to false and writes it back. Attaching assets therefore
promoted the release it was attaching them to. v0.13.0 and v0.14.0 were
both published as pre-releases and both ended up marked as the latest
release within the hour, which also fired `released` and pushed :latest
to GHCR. Each workflow now carries the release's own flag.

The updater took the newest non-draft release, pre-releases included.
That was deliberate when it was written, because /releases/latest hides
pre-releases and every release was one, so tracking stable meant nobody
would ever be notified. With the flag preserved, a pre-release is now
genuinely a pre-release, and offering it would push unverified builds to
everyone. It now takes the newest release that is neither draft nor
pre-release, so a release reaches users only once it has been promoted.

The e2e stub grew an unpromoted pre-release ahead of the stable one, and
the spec pins which of the two the card names.

Co-authored-by: Thales <>
2026-08-24 13:12:04 +01:00
dependabot[bot] d0e6d01971 chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#426)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.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-24 11:07:55 +01:00
Tha.Les 9b655d5a07 Windows: leaner portable package and opt-in in-app updater (#421) (#423)
* feat(windows): leaner portable package and opt-in in-app updater (#421)

Issue #421 asked for Python embedded in a single EXE so updating would not
mean copying ~20k loose files over an existing install. A onefile EXE is not
viable for this stack (onefile modes re-extract the whole multi-GB payload on
every launch, and torch/onnxruntime fight frozen-import hooks), so this
addresses the root cause instead: ship less, and stop making users hand-copy a
full zip for a release that only changed app code.

Leaner package (make-portable.ps1):
- Stripping is now unconditional. The -StripVenv opt-in gate was a silent
  regression risk: nothing stopped a future workflow edit from shipping the
  unstripped venv with no error.
- Also strips stdlib base/Lib/test and per-package test/tests dirs.
- Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a
  package, and install_cuda_torch pip-installs into this venv on every NVIDIA
  machine at first run; removing it yields "Failed to uninstall ... missing
  RECORD file".
- Adds a post-strip import check so an over-aggressive strip fails the build
  rather than a release.

Updater (main.rs, catalog.js):
- New commands installed_runtime_id, download_app_update, apply_app_update.
- Opt-in: the check on launch is unchanged, but download and apply are each an
  explicit click. It never auto-applies and never interrupts a running job.
- Replaces StemDeck.exe and backend/ only. python/ is never touched, because an
  NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled
  skips ensure_torch_device once the device is cuda, so swapping the directory
  would silently drop that machine to CPU with no recovery.
- The runtime id (uv.lock + interpreter major.minor) is a compatibility gate,
  not a download trigger: if a release changed the Python dependency set the
  updater stands down and points at the full download. Only 19 of the last 200
  commits touch uv.lock, so the fast path covers most releases.
- apply_app_update stages and validates everything before any destructive
  rename, stops the backend synchronously first (the existing stop_backend
  returns before the process dies, which would have made every update fail on
  Windows), and retries renames past transient AV/indexer handles.
- Known gap, documented in code: the two exe renames are not atomic. A hard
  crash in that window leaves StemDeck.exe.old needing a manual rename. Closing
  it needs a bootstrap launcher that is never itself replaced.

CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the
unchanged full zips. Fresh installs are unaffected.

i18n: the 5 new strings are translated into all 7 language tables, not just
English. t() falls back to English silently, so an English-only key looks
correct in testing and ships untranslated to six locales.

Verified: Windows and Linux (WSL) both compile clean with no new clippy
warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit
tests pin the JSON contract between the PowerShell writer and the Rust reader.
Not yet verified: no end-to-end run against a real release.

* fix(updater): make the in-app update actually work, verified end to end (#421)

Built both packages on a real Windows box and drove the whole flow. Four bugs
that only surfaced by running it, none of which static checks could see.

1. Stale version after updating. app_version() read installed dist metadata,
   which lives in python/ -- the directory the updater deliberately never
   replaces. A self-updated install kept reporting the old version and would
   re-offer an update it had already applied, forever. It now prefers the app
   layer's static/version.json, which moves with backend/. Gitignored, so Docker
   and source checkouts still fall through to the hatch-vcs metadata.
   Proven: after a real update, python/ dist-info says 0.13.0 while /api/health
   reports 0.13.1.

2. The page CSP blocked the whole feature. The UI is served over http by the
   Python backend, so its connect-src applies: api.github.com is allowed,
   github.com and objects.githubusercontent.com are not, and that is where
   release assets live. Fetching the checksum and runtime id from JS was
   refused, so the pill would simply never appear. Those two reads moved into
   Rust (check_app_update), whose HTTP client is not bound by the page CSP, so
   the policy from #171 stays exactly as tight as it was.

3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not
   ACL-gated but plugin commands are, and the capability does not cover the
   remote http origin the UI is served from. The progress bar is now
   indeterminate instead of granting a remote origin event permissions to put a
   percentage on a 5 MB download.

4. The post-strip import check re-bloated the package. Running Python
   regenerated 1,912 files / 39 MB of __pycache__ that the strip had just
   removed, cancelling nearly all of it: the net saving was 180 files. Swept
   once after the last interpreter run, and backend/ no longer ships a
   developer's local __pycache__ either.

Also: the *.old sweep now runs on every launch rather than only on a version
change. apply_app_update relaunches then exits, so on the first launch of the
new build Windows still holds StemDeck.exe.old open, the delete fails silently,
and gated on a change that already happened it would never retry. Observed for
real: 15.7 MB stranded. Verified swept on the next launch.

UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the
zip stays one click away and is the escape hatch if an update fails.

Measured against the published v0.13.0 package: 18,143 -> 16,056 files
(-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path
itself: 5 MB and 123 files instead of 284 MB and 16,056.

Verified on this machine: a real 6-stem Demucs separation through the stripped
package; the full notify -> Update now -> download -> restart -> relaunch cycle,
after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only
and python/ were all untouched; and the safety gate correctly declining, with
no download attempted, when the release's runtime id differs.

Not covered: the NVIDIA package was not built, though the risk that motivated
the gate is structurally gone now that python/ is never swapped.

* fix: address code-quality review on the version-source change (#421)

Both findings from the automated review were fair.

Narrow the bare `except Exception: pass` in app_version() to
(OSError, ValueError, AttributeError). That is bandit B110, which this repo's
own security conventions call out. The three cover every real failure here --
absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is
not an object so has no .get -- while letting an actual bug in the function
surface instead of silently degrading the reported version. Bandit now reports
no issues for the file.

Use one import style in test_health_api.py so app.main is no longer imported
both as `import app.main as main` and `from app.main import app` in the same
module. Also added a "[]" case: JSON that parses but is not an object, which is
the AttributeError branch the narrowed except now names explicitly.

* feat(updater): extend the in-app update to Linux (#421)

Linux ships the same shape as Windows -- executable, backend/ and python/ side
by side -- so the updater generalises rather than needing a second design. The
platform-specific parts are now three small seams: the archive format, the
executable name, and one new gate.

Rust:
- widen the updater's cfg gates from `windows` to `any(windows, linux)`, and
  replace extract_zip_archive with extract_update_archive, which uses zip on
  Windows and the existing extract_tar_archive on Linux
- APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe
- stop_backend_and_wait now sends SIGTERM and waits before escalating on unix,
  matching what stop_backend already does on window close
- new app_root_is_writable gate: packaging/linux/install.sh offers a global
  install into /opt/stemdeck, which is root-owned while the app runs as the
  user. check_app_update declines up front rather than failing part way through
  a swap. Windows portable installs are user-writable by construction, but the
  probe is cheap and honest on both.

tar rather than zip on Linux is deliberate: it preserves the executable bit. A
zip would land StemDeck without +x and the relaunch after an update would fail
with a permission error.

Packaging (scripts/linux/make-portable.sh):
- write python/runtime-version.json using the same uv.lock + interpreter
  major.minor formula as the Windows script, so the compatibility gate behaves
  identically on both
- bring the strip to parity: stdlib test/, per-package test/tests, a post-strip
  import check, and a final __pycache__ sweep after the last interpreter run
- PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and
  the runtime marker; wired into the CPU build in linux-release.yml since
  StemDeck and backend/ are identical between both variants

Frontend: updaterAssetNames() maps the platform to its asset names, and the
wiring is gated on that rather than on os === "windows".

macOS is deliberately still excluded, and the comments now say why rather than
just that it is: backend_dir() resolves the backend inside the downloaded
runtime pack rather than the .app, so its app layer is a different thing and
the existing runtime-pack updater already covers most of it.

Verified: both platforms compile clean with no new clippy warnings, 42 tests on
Windows and 43 on Linux (the extra one is the read-only-root gate, which is
meaningless on Windows). The app-layer archive was round-tripped on Linux to
confirm it contains exactly StemDeck + backend/, that python/ does not leak
into it, that the executable bit survives, and that replacing a running binary
works. Not yet run end to end against a real Linux release.

* fix(updater): see pre-releases, and compile the Rust in CI (#421)

Two gaps that would each have undermined the update flow on release day.

The update check polled /releases/latest, which GitHub defines as the most
recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release
box ticked and it becomes invisible: no notification, no update button, on any
platform, with nothing in the logs to explain it. StemDeck has always published
even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false),
which is the only reason this has not bitten yet -- it was a trap waiting on
someone ticking a box. Now polls the releases list and takes the newest
non-draft, so it is correct either way. Drafts stay excluded: they are already
invisible unauthenticated, and a maintainer should not be offered a release
whose assets do not exist yet.

windows-check.yml and macos-check.yml now also run on pull requests that touch
desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600
lines of mostly cfg-gated Rust across two commits and every CI check passed
without compiling a single line of it; the comment at the top of
windows-check.yml notes that exact gap already shipped a broken Windows build
in v0.11.1's first release attempt. Scoped by path so the self-hosted runners
see no extra load from the majority of PRs, which never go near src-tauri.

This also gets the macOS branch compiled for the first time. Local verification
covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the
three updater commands has never been near a compiler.

* test(e2e): match the releases-list shape the app now polls (#421)

The update-check stub returned a single release object, which was right for
/releases/latest. The app now polls the releases list so a pre-release is still
seen, so the fixture has to return an array or checkForUpdate bails and the
release card never appears.

Caught by frontend-e2e on the previous commit, which is the suite doing exactly
its job: the only assertion that covers this path is
report-failure.spec.mjs:98, and it went red immediately.

* feat(i18n): add French, and make the runtime id line-ending independent

French is a complete table, not a partial one: 435 keys, the same set German
and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms
and the bare upload.skippedFiles, plus singular forms for the three
playlist.skip.* families). French takes the one/other buckets, so plural()
needs no change.

Verified with the checks from .claude/rules/i18n.md: the drift check reports
clean, and separately there are zero {placeholder} mismatches and zero HTML tag
mismatches against English. The 27 strings identical to English are genuinely
identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port,
the brand names, CUDA (NVIDIA), MPS (Apple Silicon)).

Separately: the runtime id was being computed from the raw bytes of uv.lock, so
a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and
the same lockfile produced two different ids -- caught by building the Linux
package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced
py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still
worked, but the id would shift spuriously if a runner's autocrlf ever changed,
silently declining app-only updates that were in fact compatible. Both scripts
now hash the content with newlines normalised; PowerShell, bash and a reference
Python implementation all agree on d74d6ef80c5e9d1f.

* chore: pin Unraid template to 0.14.0

Per .claude/rules/unraid-template-version.md this is an explicit decision each
time, not a default. Confirmed for this release.

The 0.14.0 GHCR image is published by docker-publish.yml when the release is
created, so the tag exists shortly after this lands.

---------

Co-authored-by: Thales <>
2026-08-23 21:29:15 +01:00
Tha.Les bf561a6c81 Lead/backing vocal split, stems relocation fixes, eager model pre-download (#406)
* Add on-demand lead/backing vocal split, fix stems relocation bugs, and eager model pre-download (#275, #403)

Lead/backing vocal split:
- New on-demand POST /api/jobs/{id}/vocal-split endpoint, running UVR-MDX-NET
  Karaoke 2 (audio-separator) as a second pass over Demucs's vocals.wav
- Desktop and mobile UI toggle to request the split, auto-chained once the
  base separation finishes, for both foreground and background jobs
- Mixer shows Lead Vocals / Backing Vocals lanes in place of Vocals once split

Stems relocation fixes (#403):
- user-data.json (library metadata) now lives inside the jobs folder so it
  follows a Settings relocation instead of staying behind in Documents
- The relocation endpoint's settings persist step was silently swallowing
  write failures and reporting false success; it now reports persisted:
  false and the Settings UI shows a clear warning instead

Desktop setup wizard:
- Demucs, beat-this, and the karaoke model now download eagerly during
  first-boot setup instead of lazily on first use

Also:
- Credit audio-separator / Ultimate Vocal Remover in the README per its
  license's attribution requirement, plus a license audit in docs/models.md
- Add models/ to .gitignore

* ci: install build-essential so diffq (audio-separator's dependency) can compile

diffq has no prebuilt wheel for Python 3.11+ on Linux, its last release only
ever shipped cp310 wheels, so uv sync must compile it from source, which
needs gcc. Docker and the Linux desktop release build already install
build-essential for the same reason; the plain lint/test CI container never
needed it before audio-separator (#275) pulled diffq in.

* chore: pin Unraid template to 0.12.0

This PR ships as v0.12.0, per the user's decision given it introduces the
new lead/backing vocal split feature.

---------

Co-authored-by: Thales <>
2026-08-21 17:43:03 +01:00
Thales 405fec28a1 Revert "ci: sign the Windows release executable with SignPath (#394)"
This reverts commit 7eba6340ef.
2026-08-20 22:12:16 +01:00
Tha.Les 7eba6340ef ci: sign the Windows release executable with SignPath (#394)
SignPath's OSS tier requires every job leading up to a signing request to run
on a GitHub-hosted agent, but the Windows release runs entirely on the
self-hosted runner. Split the workflow instead of moving the whole build:

- New `sign-exe` job on `windows-latest` builds only StemDeck.exe, uploads it
  as a workflow artifact, submits it to SignPath, and republishes the signed
  binary as an artifact. Version files are stamped before the build because
  SignPath restricts Foundation projects on PE product name and version.
- `build-and-upload` now depends on it, downloads the signed executable, and
  packages both variants around it via a new `-PrebuiltExe` flag on
  make-portable.ps1. That also removes the redundant second Rust build the CPU
  package used to trigger.
- make-portable.ps1 rejects an unsigned prebuilt binary before it reaches the
  zip, and the scan step reports the Authenticode status of what was packaged.
- workflow_dispatch entry point plus a release-only guard on the upload step so
  the integration can be exercised against a test-signing policy without
  cutting a tag.

Requires repo secret SIGNPATH_API_TOKEN and repo variable
SIGNPATH_ORGANIZATION_ID.

Adds the code signing policy and attribution required by the SignPath
Foundation terms.
2026-08-19 10:43:31 +01:00
Thales 1954bfda8c windows-check.yml: use powershell, not bash
bash on this self-hosted Windows runner mangles the auto-generated temp
script's path (backslashes stripped), the same thing windows-release.yml's
own steps already worked around by using powershell. Copied macos-check.yml's
shell: bash default without adjusting for the platform - caught by this
workflow's own first real run (exit 1 on cargo fmt --check, before it even
reached the code).
2026-08-17 23:10:51 +01:00
Thales 5f5fead50c Add an on-demand Windows Rust check on the self-hosted runner
Companion to macos-check.yml. Justified twice over in one session: first by
finding pre-existing macOS-only clippy issues nothing had ever caught, then
for real when download_file silently lost its #[cfg(unix)] gate and shipped
a broken Windows build in v0.11.1's first release attempt - undetected by
ci.yml (100% ubuntu-latest) or macos-check.yml (macOS also satisfies unix,
so it never exercised the Windows-only code path either).

Same shape as macos-check.yml: build/clippy/test only, workflow_dispatch
only, never touches packaging or uploads.
2026-08-17 23:05:52 +01:00
Thales 2bf03d05b1 macos-check.yml: suppress 3 pre-existing macOS-only lint findings
This workflow's first run found unused_variables/dead_code in
ensure_torch_device, classify_cuda_install_error, and
child_output_with_timeout -- none touched by this branch. Nothing has ever
type-checked the macOS target before this workflow existed, so these predate
it rather than being caused by it. Suppressed the same way as the 3
pre-existing Linux-side clippy lints, so a real regression isn't lost in
known noise.
2026-08-17 22:01:31 +01:00
Thales 84108250cf Add an on-demand macOS Rust check on the self-hosted runner
ci.yml (PR-triggered) is 100% ubuntu-latest, so code behind
#[cfg(target_os = "macos")] has never had a real compiler pass before
merging - that cfg-gated code is stripped before semantic analysis even
starts on a non-matching target, not just skipped at test time.
macos-release.yml already has a real macOS runner, but only fires on
release: published and does the full signed/packaged build.

This is deliberately narrow: build/clippy/test only, workflow_dispatch only
(not on every PR/push, so the runner's load and cost don't change from
today), never touches signing, packaging, or uploads.
2026-08-17 21:53:46 +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 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
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
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 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
Tha.Les a857f60fbf feat(player): stream stems on desktop via the 5s-chunk engine (#261) (#264)
* feat(player): stream stems on desktop via the 5s-chunk engine (#261)

Desktop previously decoded every stem in full before playback (~420 MB / slow
preload). The Range-based chunked engine (chunkedAudioEngine.js) already streams
glitch-free on mobile: 5s HTTP-Range windows scheduled on AudioBufferSourceNodes,
first audio after ~1 chunk, ~28 MB RAM, no length cap. This promotes it to the
desktop player as the default, closing its three feature stubs so there's no
regression vs the full-decode engine:

- chunkedAudioEngine: implement setLoop (scheduler jumps to loop.start on
  crossing loop.end, and caps lookahead at loop.end); add a per-stem AnalyserNode
  (gain -> analyser -> master) + getAnalyser for live VU.
- player.js: engineMode() selects chunked by default; "fulldecode" and "0"
  (legacy <audio>) remain opt-in via the stemdeck.audioEngine flag. The RAM cap
  now only gates the full-decode engine. On the chunked path, drive lane
  mini-waves + the energy baseline from peaks.json and VU meters from the
  engine's live analysers (overview waveforms already come from peaks.json).
- mixer.js: renderRealMiniWaveFromPeaks (peaks-based lane mini-wave).

The original "buffering issue" was the N-<audio>-element multitrack path
(HTTP/1.1 6-connection-cap underruns on Safari/WKWebView); the chunked engine
avoids it by construction. Full-decode stays as a fallback.

* fix(player): harden chunked streaming (loop cache pinning, retry, peaks fallback)

Self-review of the chunked-engine promotion found four gaps:

- Loop-start chunk was evicted as playback advanced, so every loop pass paid a
  refetch gap. Pin it against both eviction sites while a loop is active, and
  warm it as the playhead approaches loop.end (deduped by the chunk cache).
- A transient all-stems fetch failure cached an empty chunk forever, leaving
  playback permanently silent past that point. Drop empty results so the
  scheduler retries.
- Loop jumps scheduled with the cold-start 50 ms lead. Cached (sync) starts now
  use 10 ms, making loop wraps near-seamless; the async path keeps 50 ms.
- Legacy jobs without peaks.json lost all waveform visuals on the streaming
  path. The chunked branch now falls back to the full-decode engine in that
  case (honoring the backend's documented "degrades to client-side decode"
  contract), unless the track exceeds the decode RAM cap - then it keeps
  streaming audio with placeholder waveforms.

* ci(deps-audit): ignore torch PYSEC-2026-2286 (torch.load ACE, no adoptable fix)

pip-audit newly flags torch 2.6.0 for PYSEC-2026-2286 (torch.load weights_only
deserialization -> arbitrary code execution, HIGH; fixed in 2.10.0). The exploit
requires an attacker-controlled .pth checkpoint. StemDeck never calls torch.load
on untrusted input: demucs loads only its official model weights from the
trusted torch-hub source, and users submit audio, not checkpoints. torch is
pinned <2.7 (torchaudio 2.7+ dropped the writer demucs needs), so 2.10.0 is not
adoptable yet. Documented alongside the existing ignored torch advisories.
2026-07-13 18:02:31 +01:00
Tha.Les a67b186a24 ci(docker): push :latest when a prerelease is promoted to Latest (#259)
Listen to release `prereleased` and `released` actions instead of `published`:
- prereleased (prerelease published) -> version tag only (e.g. 0.8.0-alpha.6)
- released (stable publish, or a prerelease promoted to a full/Latest release)
  -> version tag + :latest
- push to main / manual dispatch -> :edge (unchanged)

Using these two actions instead of `published` also avoids a double run on a
stable publish (which fires both published and released).
2026-07-12 22:55:43 +01:00
Tha.Les 1b0d689eef fix(ci): make manual/edge image version PEP 440-valid (#254)
git describe --long yields <tag>-<N>-g<sha>, which hatchling rejects as an
invalid version (SETUPTOOLS_SCM_PRETEND_VERSION). Rewrite the trailing
-N-gSHA into a +N.gSHA local segment so the docker build succeeds.
2026-07-10 22:18:24 +01:00
Tha.Les ce86e8ad57 feat(unraid): publish container to GHCR and add Community Applications app (#253)
* feat(unraid): publish container to GHCR and add Community Applications template

- add docker-publish workflow: build build/Dockerfile and push
  ghcr.io/stemdeckapp/stemdeck on release + manual dispatch (linux/amd64)
- add templates/stemdeck.xml: Unraid Docker template (port 8000,
  /app/jobs + /cache volumes, persistent library default, optional NVIDIA
  runtime vars)
- add ca_profile.xml at repo root for the CA submission scan
- document the GHCR image and Unraid install in README

The published image keeps the default Linux x86_64 (CUDA) torch wheel, so a
single image runs on CPU by default and uses the GPU when started with
--runtime=nvidia; _detect_device() auto-selects CUDA.

* ci(unraid): derive manual-dispatch version from git instead of 0.0.0

Drop the workflow_dispatch version input and compute it with git describe
(hatch-vcs style) so manual builds carry a real dev version. Fetch full
history + tags on checkout so git describe resolves.

* ci(unraid): publish a rolling :edge image on merge to main

Add a push trigger on main so every merge builds and pushes
ghcr.io/stemdeckapp/stemdeck:edge. :edge never moves :latest, which stays
reserved for stable releases.

* chore(unraid): point template at :edge until a stable release exists

* docs(unraid): document edge/latest/version image tags and use :edge in the run example

* chore: default run.sh PORT to 8000 to match the container/Unraid port

* chore: default advertised port to 8000 across backend and desktop

Align DEFAULT_PORT (app/core/settings.py) and the desktop launcher's
configured_port() fallback (desktop/src-tauri/src/main.rs) from 8080 to 8000
so every path -- container, run.sh, and desktop -- shares one default. Update
the settings comment and the port-default test accordingly.
2026-07-10 22:00:13 +01:00
Thales Pereira 7a15a395e1 ci: remove append_body from OS release workflows - artifact sections now static 2026-06-30 14:21:24 +01:00
dependabot[bot] 8d72f0d684 chore(deps): bump actions/checkout from 6 to 7 (#214)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 16:41:14 +01:00
dependabot[bot] c95b42ae48 chore(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.1 (#215)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.1.
- [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/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...718ea10b132b3b2eba29c1007bb80653f286566b)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.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-06-26 16:23:16 +01:00
Tha.Les a45badf3b3 fix(ci): target the macOS runner's default macOS/ARM64 labels (#227)
The self-hosted runner advertises self-hosted/macOS/ARM64 (GitHub's
default macOS labels), but the workflow required a custom 'osx' label
that the runner no longer carries, so every macOS Release since
alpha.15 sat queued with no matching runner. Match the default labels,
consistent with the windows/linux release workflows.

Co-authored-by: Thales <>
2026-06-26 12:28:11 +01:00
Tha.Les ab832e3342 fix(ci): skip apt when deps present; the runner has no passwordless sudo (#224)
Root cause of the Linux apt hang: the wsl2 self-hosted runner does not
have passwordless sudo, so 'sudo apt-get' blocks forever at the password
prompt. The previous 'sudo timeout ... apt-get' guard did nothing because
sudo prompts BEFORE the inner timeout can start.

Since the build deps are already installed on the persistent runner, check
each package with dpkg (no sudo) and skip apt entirely when all are present.
Only touch apt if something is genuinely missing, using 'sudo -n' so it
fails fast with a clear message instead of hanging at a prompt.

Co-authored-by: Thales <>
2026-06-24 20:29:55 +01:00
Tha.Les 8210e7f22d fix(ci): make Linux apt step best-effort and hard-capped (#223)
The Linux release job hung for 30+ min on 'install build dependencies':
apt-get stalled on the persistent wsl2 runner (likely a dpkg lock held by
unattended-upgrades). The packages are already installed on that runner
from prior runs, so the install itself is a no-op -- the hang is in
apt-get update / lock acquisition.

Bound the apt commands with  and DPkg::Lock::Timeout so a stuck
lock cannot hang the release, set DEBIAN_FRONTEND=noninteractive to avoid
debconf prompts, treat apt as best-effort, then verify webkit2gtk-4.1 is
actually present (fail loudly only if genuinely missing).

Co-authored-by: Thales <>
2026-06-24 20:06:20 +01:00
Tha.Les 44336a55d5 fix(ci): source release tag from github.ref_name on all self-hosted runners (#221)
* fix(ci): source release tag from github.ref_name on Windows

The Windows release job failed at 'write version files' with
'GITHUB_REF_NAME is not set' on the org-level self-hosted win runner,
while the Linux runner saw the variable fine. $env:GITHUB_REF_NAME is
only injected by Actions runner >= 2.290, so an older self-hosted runner
leaves it empty.

Source the tag from the github.ref_name context (evaluated by Actions
before the step runs) via a job-level REF_NAME env var instead, so the
build no longer depends on the runner version. Replaces all three
$env:GITHUB_REF_NAME usages (version step + both build steps).

* fix(ci): source release tag from github.ref_name on Linux and macOS too

The Linux release job failed at 'write version files' with the same
empty-GITHUB_REF_NAME cause as Windows: the wsl2 self-hosted runner is
also older than 2.290. macOS uses the same pattern and the same class of
runner, so fix all three release workflows consistently to read
github.ref_name from context via a REF_NAME env var.

---------

Co-authored-by: Thales <>
2026-06-24 18:54:25 +01:00
Tha.Les 8131900d0a feat: Linux portable builds (CPU + NVIDIA) and release workflow (#220)
* feat: add CPU-only Linux portable build and release workflow

Adds a Linux .tar.gz portable package mirroring the existing Windows/macOS
build paths. Bundles a python-build-standalone runtime (CPU torch + demucs)
plus the Tauri binary so users extract and run ./StemDeck.

- scripts/linux/make-portable.sh: stages PBS Python, force-installs CPU-only
  torch, builds the Tauri binary, and produces StemDeck-Linux-x64.tar.gz with
  the backend/app + python/ layout find_repo_root resolves at runtime.
- .github/workflows/linux-release.yml: builds on hosted ubuntu-latest on
  release publish; installs Tauri v2 apt deps + uv, ClamAV-scans, uploads.
- packaging/linux/{README-LINUX,THIRD_PARTY_NOTICES}.txt: extract-and-run
  instructions noting ffmpeg + WebKitGTK are system (apt) prerequisites.

FFmpeg is not bundled: the Linux shell expects ffmpeg on PATH. NVIDIA/CUDA
and AppImage variants are intentionally deferred to later phases.

* fix: don't set PYTHONHOME on Linux (breaks PBS stdlib resolution)

The Linux backend failed to start with 'ModuleNotFoundError: No module
named encodings'. PYTHONHOME was being set to python/bin instead of the
prefix python/, so CPython looked for its stdlib under python/bin/lib and
could not boot.

Linux bundles python-build-standalone exactly like macOS, which detects
its own prefix by walking up from bin/ and must NOT have PYTHONHOME set.
The two PYTHONHOME sites were gated #[cfg(not(target_os = "macos"))],
wrongly including Linux alongside Windows. Only Windows -- whose portable
venv keeps the stdlib under base/Lib -- needs PYTHONHOME, so gate both
sites (start_backend and python_stdlib_ok) to #[cfg(windows)].

This also fixes the latent inconsistency where probe_runtime reported
Python ready (python_stdlib_ok set PYTHONHOME=python, the correct prefix)
while start_backend set PYTHONHOME=python/bin and failed.

* feat: add NVIDIA/CUDA Linux portable variant

Adds a second Linux package, StemDeck-Linux-x64.NVIDIA.tar.gz, with
CUDA-enabled torch baked in (mirrors the Windows NVIDIA variant).

- make-portable.sh: CPU_ONLY toggle (default 1). CPU_ONLY=0 keeps the
  project's default torch wheel, which on Linux x86_64 is the CUDA build,
  and omits the cpu-only marker so the desktop shell detects the GPU and
  uses CUDA at runtime. No app-side changes needed -- the CUDA detection/
  install path in main.rs is already cfg(not(macos)) and covers Linux.
- linux-release.yml: builds both variants in one job. CPU first (full Tauri
  build), then NVIDIA with SKIP_TAURI_BUILD=1 reusing the same binary. Adds
  a free-disk-space step (CUDA bundle is several GB) and drops each
  uncompressed stage after taring to stay within the hosted runner's disk.
- README-LINUX.txt: documents both variants and the NVIDIA driver
  prerequisite (nvidia-smi must work; CUDA runtime is bundled, no toolkit
  install needed; falls back to CPU when no GPU).

* ci: run Linux release on self-hosted linux/x64 runner

Targets the org's self-hosted wsl2 runner ([self-hosted, linux, x64])
instead of hosted ubuntu-latest, matching the Windows/macOS release
jobs. Drops the free-disk-space step: it was a hosted-runner workaround
and would needlessly rm system directories on a persistent self-hosted
box (WSL2's virtual disk has ample room for the CUDA bundle).

* ci: add workflow_dispatch test build for Linux release

Lets you run the full two-variant build + ClamAV scan on the self-hosted
runner without publishing a release, to validate the runner toolchain and
the CUDA build. Resolves the version from a manual input (default 0.0.0,
must be valid PEP 440) instead of the branch ref, and skips the upload
step on non-release events.

---------

Co-authored-by: Thales <>
2026-06-24 18:07:34 +01:00
Tha.Les 66425fe108 fix: use powershell shell on Windows runner (pwsh not available) (#212) 2026-06-18 10:31:59 +01:00
Tha.Les d6280a3efb ci: trigger release workflows only on release published (#208) 2026-06-18 08:58:50 +01:00
dependabot[bot] bca5a8961b chore(deps): bump actions/checkout from 4 to 6 (#206)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 08:45:06 +01:00
Tha.Les 3baaa0fa07 ci: migrate from Woodpecker to GitHub Actions (#205)
* ci: add GitHub Actions CI workflow (replaces Woodpecker ci.yml)

Parallel jobs: lint, test, js-syntax, sast-bandit, deps-audit, trivy.
Trivy now uses aquasecurity/trivy-action instead of the container image.

* fix: upgrade yt-dlp, starlette, python-multipart; ignore new torch CVEs

- yt-dlp 2026.3.17 -> 2026.6.9 (fixes CVE-2026-50023, CVE-2026-50574, GHSA-69qj-pvh9-c5wg)
- starlette 1.0.0 -> 1.3.1 (fixes CVE-2026-48818, CVE-2026-54283)
- python-multipart 0.0.27 -> 0.0.32 (fixes CVE-2026-53539)
- Add CVE-2025-2148/2149/2998/2999/3000/3001 to deps-audit ignore list:
  torch is pinned below 2.7 due to torchaudio/demucs compat; these CVEs
  are in ops StemDeck does not invoke.

* ci: add macOS and Windows release workflows + Dependabot config

- macos-release.yml: builds arm64 and x64 DMGs on self-hosted macOS runner,
  inspects artifacts, uploads to GitHub release via softprops/action-gh-release
- windows-release.yml: builds NVIDIA and CPU portable ZIPs on self-hosted
  Windows runner, scans with ClamAV, uploads to GitHub release
- dependabot.yml: weekly action SHA bumps for all workflows
- Both release workflows: permissions locked to read-only at workflow level,
  contents:write only on the job; concurrency guard; 120/90 min timeouts;
  workspace cleanup; all actions pinned to SHA

* ci: use osx runner label for macOS release workflow
2026-06-17 21:28:40 +01:00