279 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 <>
v0.14.2
2026-08-24 13:12:04 +01:00
Tha.Les 60a3684f89 Add slashCAM to We Recommend (#428)
German-language camera and video tech: hands-on tests, industry news and
post-production coverage.

The tile uses slashCAM's own wordmark rather than their Instagram avatar.
Instagram serves an empty shell to unauthenticated clients, so the avatar
cannot be fetched, and a wordmark cropped into a 44px circle would be
unreadable anyway. Wired as a wide logo tile; the Instagram glyph is still
added automatically from the instagram.com URL.

Co-authored-by: Thales <>
2026-08-24 12:54:37 +01:00
Tha.Les 1e248ac53e Preserve user settings across a new install (#425) (#427)
A portable install keeps settings.json inside its own folder, so extracting a new
version to a fresh folder started with no settings at all: the relocated stems
folder, port, compute device, quality and language all silently back to defaults.

The shell already restored from a per-user copy; nothing had written it since the
data directory moved. Adds the write half, seeded on first load so settings
configured by an earlier release are carried forward too.

Also pins the Unraid template at 0.14.1.
v0.14.1
2026-08-24 12:13:45 +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 aeaa35d1c3 Prefer a system FFmpeg on all desktop platforms, verified compatible (#420)
The desktop app always downloaded its own FFmpeg on macOS and Windows,
even when a working system install (Homebrew, apt, choco) was already on
PATH. On macOS this could force-install a build that doesn't run on an
older macOS than the pinned binary assumes, breaking the app outright
even though the user's own FFmpeg would have worked fine (#414).

Moves the "check PATH first" logic (previously Linux-only) into the
shared entry point so it applies on every desktop platform, and
strengthens verify_ffmpeg() to also confirm the binary has every encoder
StemDeck's export pipeline needs (pcm_s16le, flac, libmp3lame, libvorbis,
aac) -- not just that it launches, so a minimal/stripped system build
isn't accepted only to fail later during an export. The macOS primary
download source (shaka-project) now goes through this same check before
being accepted over the evermeet.cx fallback, so an incompatible primary
build no longer gets used just because its checksum matched.

Docker is unaffected -- it installs FFmpeg via apt in the image and never
runs this download path.

Co-authored-by: Thales <>
v0.13.0
2026-08-22 08:57:55 +01:00
Tha.Les b1acc5b5b3 Add German, Portuguese, and Indonesian translations (#415)
* Add German, Portuguese, and Indonesian translations; fix i18n coverage gaps

Extends the existing English/Polish/Japanese/Simplified Chinese i18n system
to seven languages total. Also fixes several pre-existing i18n coverage gaps
found while auditing: the recent-tracks list, search placeholder, and trash
empty-state were hardcoding English text instead of using the translation
system; a presence-panel legend lacked data-i18n attributes; upload/job/
playlist error toasts were untranslated; and library list content did not
refresh on a live language switch. Widened the settings dropdown to fit the
longest new language name and the device-select to stop truncating longer
translated values.

Bumps the Unraid template pin to 0.13.0.

* Remove unused plural import in job.js

Flagged in PR review: job.js imports plural from i18n.js but never calls
it, only t().

* Native-speaker QA pass on all translations

Fixes real mistranslations (German "schleifen" for loop, "Skala" for musical
scale, Indonesian countdown/count-in mixup, Chinese Alpha badge), grammar bugs
(Polish aria-labels requiring an unavailable grammatical case, singular/plural
adjective agreement in playlist skip messages), inconsistent terminology
within each language, and a stray three-dot ellipsis instead of the single
character used everywhere else.

Converts playlist.skip.* from t() to plural() with proper singular/plural
forms across all seven languages, since Portuguese and Polish adjectives
don't inflect correctly as flat strings.

---------

Co-authored-by: Thales <>
2026-08-22 08:32:51 +01:00
Tha.Les b11a39a4c9 chore: pin Unraid template to 0.12.2 (#413)
Co-authored-by: Thales <>
2026-08-22 00:28:46 +01:00
Tha.Les 96c9a86482 polish: compact DAW summary bar, distinct mute color, We Recommend updates (#412)
- Footer overview waveform bar shrunk from 52px to 31px; it and the beat
  grid overlay already resize off clientHeight so no JS changes needed.
- Track/meta summary cards (Key, BPM, LUFS, etc.): vertical padding cut
  from 14px to 4px and content centered vertically, so cards without a
  sub-label (BPM, Vocal Presence, ...) don't sit top-anchored next to
  taller ones that have one.
- Mute button now lights up blue when engaged, mirroring the solo
  button's own lit-up gold treatment -- it previously just faded to
  opacity 0.35 with no distinct "pressed" color, unlike solo.
- We Recommend: added Beltr, and rewrote the flatter one-line
  descriptions (Analog4Lyfe, Dlima Guitars, Empress Effects, Joao
  Gaspar, Kris Luthier, Lisbon Guitar Works, Thomann) to be more
  specific and readable. Also fixed catalog.js's in-app list, which had
  drifted from README.md (stale "YouTube channel" role, two entries
  missing a role line entirely).

Co-authored-by: Thales <>
v0.12.2
2026-08-22 00:05:16 +01:00
Tha.Les ff6efd00c1 feat: default new portable installs' jobs folder inside the package (#411)
## Summary
Stacked on #410 (needs that merged first, or the diff below will include
its commit too).

- Windows portable builds already keep `cache`/`models`/`settings.json`
inside the package next to `StemDeck.exe`, via `local_data_dir()`'s
portable redirect (#399). The jobs default never plugged into that and
always resolved to `~/Documents/StemDeck/jobs` regardless of
portable-ness.
- `documents_dir_for_jobs()` now defaults **new** portable installs to
`local_data_dir()/jobs` (i.e. `<exe dir>/data/jobs`), consistent with
cache/models.
- **Existing installs are unaffected.** If `~/Documents/StemDeck/jobs`
already has anything in it, that stays the resolved default - checked
directly against disk content rather than a migration flag, since
there's no explicit `jobs_dir` recorded in `settings.json` for an
implicit default, and only the Python backend writes that file (kept
this self-contained rather than crossing that boundary).
- Non-portable installs (installer builds, macOS, Linux) are untouched
either way - the original Documents rationale (visible in
Finder/Explorer, OneDrive/iCloud backup, survives reinstalls) still
applies to them.
- Manual relocation via Settings still overrides everything, unchanged.

## Test plan
- [x] `cargo clippy --all-targets` clean (3 pre-existing, unrelated
warnings)
- [x] `cargo test` - 36/36 pass (3 new tests for
`directory_has_entries`)
- [ ] CI green
2026-08-21 22:35:52 +01:00
Tha.Les abf09ab5b8 fix: stop recreating the default jobs folder after relocation (#403 part 2) (#410)
## Summary
- Follow-up to #403: after both prior fixes shipped in 0.12.0, the
reporter confirmed the relocated library metadata now moves correctly,
but StemDeck still recreates `~/Documents/StemDeck/jobs` on every
startup even when the library has been relocated elsewhere via Settings.
- Root cause: `documents_dir_for_jobs()` in
`desktop/src-tauri/src/main.rs` eagerly `create_dir_all`'d the Documents
default just to compute the value passed to the backend as
`STEMDECK_DEFAULT_JOBS_DIR` - a fallback, not a pin. The Python side
(`app/core/config.py`'s `ensure_runtime_dirs`) already creates whichever
`JOBS_DIR` actually wins the settings precedence, so eagerly creating
the unused default on the Rust side was pure side effect with no
purpose.
- Fix: `documents_stemdeck_dir()` and `documents_dir_for_jobs()` now
only compute paths, never create directories. The one real use site
(`documents_store_path()`, which is about to read/write `user-data.json`
in whichever folder is actually current) already calls `create_dir_all`
itself right after resolving the path, so behavior there is unchanged.

## Test plan
- [x] `cargo clippy --all-targets` clean (3 pre-existing, unrelated
warnings)
- [x] `cargo test` - 33/33 pass
- [ ] CI green
- Manual: relocate stems folder via Settings, restart, confirm
`~/Documents/StemDeck/jobs` is no longer recreated
2026-08-21 22:12:42 +01:00
Thales e5870e5782 feat: default new portable installs' jobs folder inside the package
Windows portable builds already keep cache/models/settings.json next to
StemDeck.exe (local_data_dir(), #399); the jobs default never plugged into
that and always resolved to ~/Documents/StemDeck/jobs regardless of
portable-ness.

documents_dir_for_jobs() now defaults new portable installs to
local_data_dir()/jobs instead. Existing installs are unaffected: if
~/Documents/StemDeck/jobs already has anything in it, that stays the
resolved default (checked against disk content directly, since there is no
explicit jobs_dir in settings.json to record an implicit default -- and
only the backend writes that file). Non-portable installs (installer
builds, macOS, Linux) are untouched either way.
2026-08-21 22:04:14 +01:00
Thales 67ecf3dde6 fix: stop recreating the default jobs folder after relocation (#403 part 2)
documents_dir_for_jobs() eagerly created ~/Documents/StemDeck/jobs on every
startup just to compute the value handed to the backend as
STEMDECK_DEFAULT_JOBS_DIR, even when the user had already relocated their
library elsewhere via Settings and this default is never used. The Python
side (app/core/config.py's ensure_runtime_dirs) already creates whichever
JOBS_DIR actually wins that precedence, so the Rust side only needs to
compute the path, not create it.
2026-08-21 21:40:07 +01:00
Tha.Les 5de71e258c chore: pin Unraid template to 0.12.1 (#409)
Co-authored-by: Thales <>
2026-08-21 18:59:44 +01:00
Tha.Les b50e221ac0 fix: pin librosa <1 and add audioread explicitly (#407) (#408)
librosa 1.0.0 dropped its audioread dependency, but audio-separator
(vocal split, #275) still imports audioread directly. uv.lock already
resolved librosa 0.11.0, so dev/CI never hit this, but the packaging
scripts install from pyproject.toml rather than the lockfile, so an
unbounded upper bound let the shipped macOS/Windows/Linux runtime
packs silently pick up librosa 1.0.0 and lose audioread, breaking only
the vocal-split subprocess in the packaged artifact.

Also add audio_separator/onnxruntime to all three packaging scripts'
import verification, platform-guarded on Intel macOS, so a broken
vocal-split dependency chain fails the build instead of shipping.

Co-authored-by: Thales <>
v0.12.1
2026-08-21 18:55:23 +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 <>
v0.12.0
2026-08-21 17:43:03 +01:00
Tha.Les 306f2ce913 Portable Windows data dir + auto-clear resolved failure notifications (#402)
* Redirect Windows portable zip cache data to data/ next to the exe

FFmpeg, Demucs models, config, and logs currently write to %LOCALAPPDATA%
regardless of where the zip is extracted, not to the data/ folder the README
already describes. A portable.txt marker, shipped in every future Windows zip,
switches local_data_dir() to the exe-relative data/ folder that packaging
already stages.

Jobs/library data is deliberately left untouched: it stays on its existing
default (~/Documents/StemDeck) and remains relocatable via the existing
Settings -> StemData location picker (#354). Defaulting it into the
exe-adjacent folder was the design in an earlier attempt at this fix, and was
reverted -- that folder is exactly what a user deletes or overwrites thinking
it's disposable.

Fixes #399

* Auto-clear failure notifications once they're resolved

Failure notifications (import/playback/export/update) persist until manually
dismissed, deliberately, from #359 -- so a crash or reload doesn't lose the
evidence needed for a bug report. This adds a second, independent trigger on
top without touching that: a notification also clears once the thing it was
about is actually resolved, while still surviving a plain reload in the
meantime.

- import: clears when a re-import supersedes the failed track, or when the
  track is trashed/purged
- playback: clears when the same track plays back successfully
- export: clears when the same track exports successfully (jobId is
  snapshotted at click time, not read live at settle time, since settling can
  take up to EXPORT_BUSY_MAX_MS and the user may have switched tracks by
  then); log export clears separately, keyed by kind since it has no jobId
- update: clears on the next successful check, which in practice only happens
  on the next app start -- checkForUpdate() has no periodic re-check today

Fixes #401

---------

Co-authored-by: Thales <>
v0.11.3
2026-08-21 01:00:21 +01:00
Tha.Les 882b23824b chore: pin Unraid template to 0.11.2 (#398)
Co-authored-by: Thales <>
2026-08-20 22:28:43 +01:00
Tha.Les eb1a997036 Relock yt-dlp to 2026.8.19 to fix YouTube 403 on download (#397)
YouTube changed how it serves media streams; the pinned 2026.7.4 build 403s
fetching the actual audio stream while yt-dlp's latest release succeeds.
Confirmed by reproducing the failure with the old pin and a clean download
with the new one against the same video.

Co-authored-by: Thales <>
2026-08-20 22:24:56 +01:00
Tha.Les 81a96cf4f6 Revert SignPath CI until the integration is finished (#400)
SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID aren't configured in the
repo yet, so a release-triggered run of the sign-exe job added in #394
fails on main today, not just waits on approval. That blocks cutting any
release directly from main until SignPath is finished.

This reverts #394 so main goes back to the pre-SignPath, working Windows
release path. That's what let v0.11.2 skip the blocker (cut from a
branch off the v0.11.1 tag) instead of going through main; with this
reverted, main itself is unblocked for normal direct-to-main work and
future patch releases.

Re-apply when picking SignPath back up: git revert this commit (or
cherry-pick 7eba634 again) once the secrets/variable are set and the
SignPath project/policy is approved, then cut v0.12.0.

## Test plan
- [x] Clean revert, no conflicts beyond the already-open Unraid pin PR
(#398), which targets a different file section
2026-08-20 22:19:01 +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 4793422c2a star gazing :) 2026-08-20 15:01:48 +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
Tha.Les 2dcebfc997 windows-check.yml: use powershell, not bash (#393)
## Summary

\`windows-check.yml\`'s first real run failed immediately on \`cargo fmt
--check\` with \`bash: ...sh: No such file or directory\` - not a real
formatting issue, an infrastructure one. \`shell: bash\` on this
self-hosted Windows runner mangles the auto-generated temp script's
Windows-style path (backslashes stripped). \`windows-release.yml\`'s own
steps already use \`shell: powershell\` for exactly this reason; this
workflow copied \`macos-check.yml\`'s bash default without adjusting for
the platform.

## Test plan

Purely CI config. Will dispatch \`windows-check.yml\` against \`main\`
after merging to confirm it actually runs clean this time.
2026-08-17 23:14:44 +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
Tha.Les 3fd3a29ee0 Add an on-demand Windows Rust check on the self-hosted runner (#392)
## Summary

Companion to \`macos-check.yml\`, and this time justified by a real
incident rather than a hypothetical: \`v0.11.1\`'s first release attempt
shipped a broken Windows build. \`download_file\` silently lost its
\`#[cfg(unix)]\` gate while being refactored (a new helper inserted
directly above it took over the attribute, since a Rust \`#[cfg]\` only
applies to the single following item) and started compiling - and
failing to compile, since it called a still-\`#[cfg(unix)]\`-gated
helper - on Windows too. \`ci.yml\` is 100% \`ubuntu-latest\` and
\`macos-check.yml\` also satisfies \`cfg(unix)\`, so neither could have
caught it. Only an actual Windows compile could, and none existed until
now.

Fixed directly on \`main\` as an emergency hotfix (bypassed branch
protection given the release was live and broken; verified natively on
Windows, macOS CI, and WSL before pushing) and the release was re-fired
successfully - this PR is the follow-up so it doesn't happen again
silently.

## Test plan

Purely additive CI config, inert until manually triggered. No app code
touched.
2026-08-17 23:09:00 +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 1d537a7978 Fix Windows build: download_file lost its #[cfg(unix)] gate
E0425: cannot find function curl_exit_is_retriable in this scope, on Windows
only. When curl_exit_is_retriable was added directly above download_file,
its own #[cfg(unix)] attribute only applies to the single following item -
it silently stopped covering download_file too, which had carried that gate
before. Linux and macOS both satisfy cfg(unix), so neither build (nor the
new macos-check.yml) could have caught this; only an actual Windows compile
could, and none exists in CI. Verified natively on this machine: cargo
build/clippy/test all clean (two other findings, pip_pid unused and a
needless_return, are pre-existing Windows-only issues unrelated to this
change, suppressed the same way as the macOS-only ones macos-check.yml
found).

v0.11.1 published with this bug; Windows Release failed to build. Follow-up
commit re-fires the release against this fix.
v0.11.1
2026-08-17 22:34:36 +01:00
Tha.Les a3e5473bae Fix macOS FFmpeg download reliability (mirror, timeout, retry, errors) (#390)
## Summary

Two commits:

- **The fix**: `evermeet.cx` was macOS's single FFmpeg source, hardcoded
with no fallback, no connect-timeout (curl fell back to the OS default,
~60-130s), and produced an unreadable, mislabeled raw curl error on
failure (#388 is a direct hit of this). Now: a faster/clearer/retrying
download path shared with Linux, plus - macOS specifically - a real
second source (shaka-project's GitHub-Releases-hosted static builds)
with `evermeet.cx` demoted to fallback. Closes #388. Closes #389.
- **A way to actually verify it**: added `macos-check.yml`, an on-demand
(`workflow_dispatch`) build/clippy/test job on the org's self-hosted
macOS runner. `ci.yml` is 100% `ubuntu-latest`, so `#[cfg(target_os =
"macos")]` code has never had a real compiler pass before merging - this
fills that gap without changing what runs on every PR.

## Test plan

- [x] `cargo fmt --check` / `cargo clippy -- -D warnings` clean for the
`#[cfg(unix)]` shared code, verified via WSL (real compile - Linux
counts as unix)
- [x] All 4 new pinned SHA256 hashes independently verified: downloaded
fresh, hashed locally, cross-checked against the release notes' own MD5s
(exact match), confirmed valid Mach-O 64-bit binaries via magic bytes
- [ ] **Real macOS compiler pass** - not yet run. Plan to trigger
`macos-check.yml` against this branch before merging; will report
results here.
- [ ] Manual test of the fallback path actually triggering (e.g.
temporarily pointing the primary at a URL that 404s) - not yet done,
macOS-only
2026-08-17 22:18:30 +01:00
Thales c66f1d92f6 Update StemDeck repository version to 0.11.1
Matches the pattern of the actual last version-bump commit (b2379ed): only
templates/stemdeck.xml is hand-edited. 0.11.0 was never published (stayed a
draft), so 0.11.1 will be the first release carrying everything since 0.9.0 -
the template tracks that, not the skipped draft version.
2026-08-17 22:14:14 +01:00
Thales 556e949fea Merge main into fix-macos-ffmpeg-reliability, resolve macos-check.yml conflict 2026-08-17 22:08:20 +01:00
Thales 76febec320 Fix E0433: the new macOS-gated test used env:: without importing std::env
mod tests's existing imports are explicit (no glob), so std::env was never
in scope for a test written outside where it's actually used. Caught by the
new macos-check.yml workflow's first real run against this branch - Linux
never compiles this test at all (it's #[cfg(target_os = "macos")]), so WSL
had nothing to catch it with. The import itself is gated the same way so it
doesn't go unused on non-macOS builds, where nothing else in the test module
references env:: directly.
2026-08-17 22:04:40 +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
Tha.Les 91f138c261 Add an on-demand macOS Rust check on the self-hosted runner (#391)
## Summary

Bootstrapping commit split out of #390 - GitHub only allows dispatching
a `workflow_dispatch` workflow that already exists on the default
branch, so this needs to land on `main` before it can be used to
actually verify #390's macOS-specific changes.

`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.

## Test plan

Purely additive CI config, inert until manually triggered. No app code
touched.
2026-08-17 21:58:29 +01:00
Thales 6d222ec92d 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:55:03 +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
Thales 0503111601 Fix macOS FFmpeg download: no fallback, ~75s hang, unreadable/mislabeled error
Root cause of the setup wizard's "Checking FFmpeg" failures (#388): a single
hardcoded source (evermeet.cx) with no fallback, no connect-timeout (curl
fell back to the OS default, ~60-130s, before giving up), an unreadable raw
curl error, and a shared error-message helper that hardcoded "runtime pack"
regardless of what was actually being downloaded.

download_file (#[cfg(unix)], compiler-verified via WSL):
- label parameter replaces the hardcoded "runtime pack" text
- --connect-timeout 20 added, so an unreachable host fails in ~20s
- retry with backoff (3 attempts, 2s/5s) on connection-class curl exit codes
  only (6/7/28 - resolve/connect/timeout), never on a failure retrying can't
  fix, like a checksum mismatch
- a clear, actionable message replaces the raw curl dump

setup.js: the FFmpeg step now surfaces a hint pointing at the (previously
undocumented-in-UI) STEMDECK_FFMPEG_URL override on failure.

download_macos_ffmpeg (#[cfg(target_os = "macos")]): primary source is now
shaka-project/static-ffmpeg-binaries - built from source via GitHub Actions,
served from GitHub Releases (GitHub's global CDN), per-architecture raw
static binaries. evermeet.cx becomes the fallback, tried only if the primary
fails outright. An explicit STEMDECK_FFMPEG_URL override still bypasses both,
unchanged from before. All four new pinned hashes were independently
verified before pinning: downloaded fresh, sha256 computed locally,
cross-checked against the release notes' own published MD5s, and confirmed
as valid Mach-O 64-bit binaries via magic bytes.

Verification note: the macOS-specific dispatch code could not be
compiler-verified on this Linux dev machine (cfg-gated code is stripped
before semantic analysis on a non-matching target, and cross-compiling to
macOS fails without Apple's toolchain). Syntax-valid per cargo fmt and
carefully hand-traced, but needs a real macOS compiler pass - see the
companion macos-check.yml workflow.

Closes #388, #389
2026-08-17 21:53:38 +01:00
Tha.Les 57d44f5057 Fix reporting flow, stem-collection availability, and YouTube import gaps (#387)
## Summary

Five commits:

- **Stem collections**: unavailable/broken tracks now get a real
server-side check and a one-click reimport, on both desktop and mobile.
Closes #380.
- **YouTube import**: `/live/`, `/embed/`, and `youtube-nocookie.com`
links now work. Closes #382.
- **Duration ceiling**: raised from 20 to 60 minutes - the Settings API
was silently clamping past that regardless of what was requested. Closes
#383.
- **Notification centre report flow**: fixes the Windows bug where
"Report on GitHub" opened File Explorer instead of the browser, adds a
Discord option, a full backend traceback (not just a one-line
exception), an opt-in "include recent logs" button, and anonymization of
anything headed for a public report (home directory / source URLs / IP
addresses). Closes #381. Closes #384. Closes #385.
- **Report URL direct-fill**: fills the report's Logs field directly
with the trace when it's short enough, instead of always requiring a
clipboard paste. Closes #386.
- **Unraid template**: pinned to 0.11.0, matching the one-file pattern
of the actual last version-bump commit. Note: `v0.11.0` is not yet a
published tag/release, so this pin won't resolve to a real image until
one is cut separately.

## Test plan

- [x] `ruff check` / `ruff format --check` clean
- [x] `bandit -r app/ -ll` clean
- [x] Full pytest suite: 564 passed (14 pre-existing failures unrelated
to this branch - Windows-specific: POSIX file-mode bits, process-signal
semantics)
- [x] `node --check` on every touched JS file
- [x] `tests/js/report-url.test.mjs`: 62/62 checks pass
- [x] Manual verification against the real Tauri desktop build (this was
developed and tested against the dev server; the Windows Explorer bug
specifically only reproduces through the desktop `open_url` command)
v0.11.0
2026-08-17 18:08:47 +01:00
Thales 6c3cd582c9 Update StemDeck repository version to 0.11.0
Matches the pattern of the actual last version-bump commit (b2379ed): only
templates/stemdeck.xml is hand-edited. desktop/src-tauri/Cargo.toml,
tauri.conf.json, and package.json stay at their 0.0.0 placeholder - they're
sed-rewritten transiently inside each release workflow run, never committed.
pyproject.toml's version is derived from the git tag by hatch-vcs and is
never hand-edited at all (see its own header comment).

Note: v0.11.0 is not yet a published tag/release, so this pin won't
resolve to a real image until one is cut.
2026-08-17 18:04:14 +01:00
Thales 7d0ef12302 Rework the notification centre's failure report: fix the Windows Explorer
bug, add Discord, full traceback, opt-in logs, and anonymization

Root cause of the Explorer bug: the pre-filled GitHub URL carried the full
diagnostic dump (up to 6000 chars) as a query param, and Windows opens it via
explorer.exe, which silently falls back to a plain File Explorer window past
roughly 2000 characters instead of erroring. buildReportUrl() now fills the
"Logs / screenshots" field directly with as much of the traceback/stderr
tail as fits (keeping the end, where the actual error is - no paste needed
for the common case), and only points at the clipboard for what doesn't fit.
buildReportText() always has the complete, untruncated version.

Also added:
- A second "Report on Discord" button next to "Report on GitHub".
- Full backend traceback capture (_quarantine_failed_job), not just a
  one-line exception repr - fixed a latent bug in the same change where the
  tail parser would have silently swallowed a second section into the first.
- An opt-in "Include recent logs" button pulling from the backend/
  application/setup log views already exposed by Settings -> Logs, scoped to
  a window around the failure's own timestamp.
- Anonymization (app/core/redact.py): strips the reporter's home directory,
  any YouTube/SoundCloud source URL (download.py logs every job's URL, not
  just the failing one - a raw log tail would otherwise leak everything
  imported in the fetched window), and any IPv4 address (the mobile UI talks
  to this backend over the LAN). Applied unconditionally in GET
  /api/logs/{view}, not just for the report flow, and to the per-job
  traceback/tail/exception before error.txt is ever written. title:/source:
  stay unredacted in that file on purpose - they're already excluded from
  the public API response, so redacting them there loses local diagnostic
  value for no privacy gain.

Closes #381, #384
2026-08-17 17:47:07 +01:00
Thales d0f51de6c2 Raise max track duration ceiling from 20 to 60 minutes
The Settings API silently clamped any requested max_duration_sec back down
to 1200 seconds regardless of what was sent - _DURATION_MAX was a hardcoded
product ceiling, not just a default. Full albums, DJ sets, and concert
recordings routinely exceed 20 minutes.

Closes #383
2026-08-17 17:46:47 +01:00
Thales ab3832bfa3 Accept /live/, /embed/, and youtube-nocookie.com links for YouTube import
normalize_youtube_url() rejected these outright with "could not extract a
video ID from URL" or "unsupported host". /live/<id> is what premieres and
creator livestreams keep once they end and become a normal VOD - common for
concert/DJ-set recordings. youtube-nocookie.com (the privacy-embed domain)
wasn't recognized as a YouTube host at all; added alongside /embed/<id>
support on the regular domain too.

Closes #382
2026-08-17 17:46:41 +01:00
Thales 0226907255 Surface unavailable/broken tracks in stem collections with one-click reimport
The backend now checks the stems folder on disk for every "done" job and
reports "unavailable" when it's missing, replacing the old client-side
heuristic that only reacted to a 404 on the single-job endpoint and missed
the case where the registry entry survived but the folder did not. Desktop
shows a yellow "click to reimport" warning wired to the existing
importFromUrl restore path; mobile gets the same detection and one-tap
reimport from scratch, since it had none before.

Closes #380
2026-08-17 17:46:29 +01:00
Tha.Les b2379ed529 Update StemDeck repository version to 0.10.0 v0.10.0 2026-08-16 22:14:42 +01:00
Tha.Les 309c49d399 fix(e2e): seed the fixture's peaks and beat grid where the API looks for them (#373)
seed.py wrote both peaks.json and beats.json into the job root. The pipeline
writes them under stems/ and that is the only place the endpoints look, so
both 404'd:

- GET /api/jobs/{id}/beats -> 404, the studio reported "No beat grid for this
  track", and every click-track control stayed disabled. The click track, the
  count-in and the grid editor could not be tested in a browser at all -- while
  seed.py, at a glance, looked like it covered them.
- GET /api/jobs/{id}/stems/peaks.json -> 404, so the studio fell back to
  decoding every stem for its waveforms. The precomputed-peaks path that every
  real track takes was never exercised.

The grid also carries the shape beatgrid.py emits rather than an invented one,
`bars` included: without bar marks the accent mode degrades to "Auto (none
found)" and the detected-meter path never runs.

Adds tests/e2e/click-track.spec.mjs over what this unlocks -- the grid reaching
the studio, the click toggling, count-in persisting across a reload, the rate
control reporting the tempo it is actually clicking, the accent choice, and the
grid editor opening and closing from all three of its controls.

waitForClickTrack is a helper rather than part of openStudio: the metronome is
built after the transport reports a duration, and acting before then hits a
null metronome where the rate and accent controls silently no-op. That is what
made the rate test fail first time round, and it is worth naming.

Co-authored-by: Thales <>
2026-08-16 22:01:08 +01:00
Tha.Les 2c3541d311 Report a failure from the notification centre (#372)
* feat(ui): report a failure from the notification centre

A failure used to live in a transient #error banner. Dismiss it, or reload,
and the evidence was gone -- which is the position #359 complained about,
where a reporter has nothing to paste and guesses at a cause instead. #343 is
the standing proof: its author blamed a GPU and sent the investigation the
wrong way. This session hit the same wall, a "demucs exited 1 (no stderr
captured)" that was really a missing ffmpeg on PATH.

Failures now land in the notification centre, survive a reload, and open a
dialog that can hand the whole thing to GitHub as a pre-filled bug report --
version, OS, install method, stage, device, model and the stderr tail already
in the form. The user adds what they were doing and ticks the two preflight
boxes, which GitHub cannot prefill and which are the point.

Covers import (foreground and background), playback, export and update
failures. A background import that failed used to say nothing whatsoever: no
banner, no queue UI, just a console warning and a library row identical to a
healthy one. Queue three tracks, lose one, never find out.

- Deliberately not wired into showError wholesale: it also carries benign
  validation ("Only MP3, WAV... are supported"), which must not file a bug.
- One failure, one card. The foreground SSE handler and the background queue
  reconciler can both notice the same dead job, and applyState can run its
  error branch on more than one frame, so records key on the job id.
- classify_failure()'s "unknown" sentinel is dropped rather than shown: as a
  card it read "Import failed - unknown", and as an issue title it grouped
  every unclassified failure under one meaningless heading.

Privacy: the report carries technical details only. Track title and source URL
are never included -- issues are public, and the user adds them if they help.
GET /api/jobs/{id}/failure enforces that server-side by parsing error.txt and
serving a whitelist, rather than trusting the client to filter the file.

That endpoint also closes a gap: the pipeline has written the quarantined
error.txt since #277 -- classified cause, device, model, timings, 40-line
stderr tail -- and nothing ever read it back, so the UI had only the one-line
error_detail. It is the difference between "demucs failed" and "CUDA out of
memory: tried to allocate 2.40 GiB".

The notification centre had no generic add-a-card path: one hardcoded release
card, and badge/empty-state toggled inline at its two call sites assuming
exactly one card. That is centralised in notifications.js now, with the
release card keeping its own per-version dismissal key.

Tests: tests/js/report-url.test.mjs pins the dropdown strings (an OS that does
not match an option exactly is dropped by GitHub without complaint), the URL
length ceiling, tail truncation keeping the end where the error is, and that
no title or source URL can appear. tests/e2e/report-failure.spec.mjs covers
the desktop path, where the link is intercepted and handed to open_url rather
than navigating -- a break there would do nothing in the shipped app while
working in every browser a developer tests in.

* fix(settings): registry pane stuck on "Loading…", and add the backend log view

Two Settings defects, both found by looking at the pane rather than the code.

**Registry never loaded.** loadRegistryView selected `.settings-registry-view`
unscoped, but the two log viewers reuse that class for its read-only-textarea
styling and sit earlier in the markup. The lookup therefore returned the
*application log* box: the registry JSON was written into a hidden textarea
while the registry pane kept its literal "Loading…" placeholder for ever, and
the application log showed registry JSON until it was refreshed. Scope the
lookup to the registry pane. Not web-only -- it never worked anywhere.

**backend.log had no viewer.** It was listed under Logs → Location and shipped
in the logs zip, but the only two views were application and setup, so the one
log that holds what killed a backend before its own logging was configured was
the one log you could not read in the app. It gets a "Backend log" tab beside
the other two, reading backend.log plus its two rotations.

The sub-tab wiring is already generic (loadLogTail(overlay, name)), so the tab
needed markup and a view entry, no new JS.

Tests: the backend view's window filtering and rotation ordering, plus one that
walks _LOG_FILES against _LOG_VIEWS and fails if a file the Settings pane
advertises has no view to read it in -- which is exactly how backend.log stayed
invisible.

* fix(ui): keep a failure recorded during startup from being overwritten

initNotifications assigned the stored list over whatever was already in
memory. Reading the store is async, so a failure recorded while that read was
in flight was dropped -- losing exactly the notification the user would then
go looking for. Merge by id instead, newest first.

Latent rather than observed: the current call order records nothing that
early. It is one line, and the alternative is a bug that only ever appears
when something else has already gone wrong.

* test(e2e): stop the update check reaching GitHub, and pin the shared badge

CI failed two notification tests that pass on any developer machine. The
update check hits api.github.com for real; when the published release is newer
than the version under test, an update card appears and lights the same badge
failure notifications use. The tests then saw a lit badge with no failures.
Locally it never happened, because a dev build reports a version containing
"dev" and the check skips those -- the tests were passing for the wrong reason.

Answer the update check from the test instead, which also takes an external
service out of the path of every run.

The behaviour CI caught is correct and now has a test of its own: with an
update pending, dismissing the last failure card leaves the badge lit and the
empty state hidden, because the update is still there. openStudio grows an
`updateAvailable` option that forces that state (stubbing the version too --
the check skips dev builds, so a release-looking version is required for the
card to appear at all).

---------

Co-authored-by: Thales <>
2026-08-16 21:11:43 +01:00
Tha.Les fea4fcf145 Count-in, and a transport footer rebuilt around the studio's column grid (#369)
* feat(playback): count-in before playback and exports, redesign transport footer

Count-in (#269): one bar of click count-in leads into playback and into
audio exports, independent of the running click track (a clean backing
track can still get a count-in). The lead-in math is defined once and
mirrored between metronome.js and click_render.py, pinned by parity
tests on both sides.

- Playback: audioEngine schedules stem playback on a future ctx-time
  start so the count-in clicks land in the silent gap before the song
  begins; the metronome schedules them through the same clock mapping
  the running click already uses.
- Export: stems are delayed via ffmpeg's adelay and the click WAV is
  rendered in output coordinates when a count-in is requested, so it
  isn't re-trimmed by the region -ss like a plain click.

Also rebuilds the transport footer around labelled control groups
(Transport, Position, Speed, Click Track) instead of a right-click
popover: playback speed collapses to three practice presets (0.25x /
0.5x / 1x), the click track gets an on/off toggle and a count-in
switch, and the track-info block collapses from four stacked detail
rows to one compact line.

* fix(ui): hide click-track panel by default before any track is loaded

The panel lost its default "hidden" class when it changed from a
right-click popover to always-inline (#269 follow-up) -- on a fresh
page load, before any track was ever picked, nothing forced it
hidden, so "Ready to import a track" showed a full set of live-
looking click controls for a track that didn't exist.

* polish(ui): footer wave time labels, orphan dividers, visible click-volume readout

- Time labels above the footer's mini waveform, matching the main ruler.
- Divider marks between control clusters in the footer's controls row,
  hidden via ResizeObserver when wrapping strands one at the end of a
  line with nothing after it to separate.
- Click volume percentage shown next to the slider again instead of
  screen-reader-only -- a level you can only learn by hovering isn't
  one you can reliably match between sessions.
- Count-in switched from a checkbox to a press-to-toggle button,
  matching the click on/off control beside it (both answer "is this on
  for the next play?", so they read as the same kind of control now).

* fix(playback): count-in never armed on the chunked audio engine

The chunked engine is the default playback path (engineMode() falls
back to "chunked" unless a debug localStorage flag forces
"fulldecode") -- but count-in support (play(leadIn), supportsCountIn,
a clamped getCurrentTime during the lead-in) was only ever added to
audioEngine.js, the full-decode path. Since _armCountIn() bails out
whenever eng.supportsCountIn is falsy, count-in silently never armed
for any track played through the engine essentially everyone actually
uses, and playback started immediately regardless of the toggle.

Mirrors the same fix in chunkedAudioEngine.js: play() accepts a
leadIn and schedules the first chunk that far in the future (falling
back to the existing 10ms/50ms margins when there is no count-in),
and getCurrentTime() clamps to the start offset during that gap
instead of reading negative.

Verified directly against the running engine clock (not just DOM
text, which rounds to whole seconds): the position holds at the start
offset for the full lead-in and then advances normally, pausing
mid-count-in stops cleanly with no phantom scheduled audio, and
replaying re-arms a fresh count-in.

* polish(ui): align the footer with the lane column, move track info into it

The footer's waveform strip ran the full width of the window while the lane
waveforms above it start after the 300px stems/mixer panel, so the same
position sat at two different x positions in the two strips and neither
ruler's ticks lined up with the other's.

The footer is now two columns on the studio's own grid. Everything
time-related -- the control clusters, the waveform, its ruler and the
detection note -- sits in the right column and starts exactly where the lane
waveforms start, running flush to the window edge like they do. The track
identity (art, title, meta, favourite, Export Mix) moves into the left column
under the mixer panel and shares its width and 14px padding, so titles, stem
names and the "Mixer" heading share one left edge down the page. That also
drops a whole row from the footer: 255px tall where the three stacked tiers
were 318px.

- The 300px is now --daw-col-w, read by the stems panel, the label cell above
  it and the footer, instead of being hardcoded in each.
- The waveform strip is full-bleed with top/bottom rules rather than a
  rounded inset panel: a side border would have offset the canvas by its own
  width, which is exactly the misalignment being fixed.
- Both rulers share tickStep(), so a time is labelled at the same x in each.
- The export menu opens up and to the right; right-aligned from the left
  column it would have hung over the sidebar.

Grid becomes a press-to-toggle button matching the click and count-in buttons
beside it -- click opens the editor and lights it, click again closes it. Its
lit state is synced inside toggleBeatGridEditor, the one place every open and
close runs through, so Done, Escape and losing the beat grid all leave the
button correct. The G shortcut is gone: the button says what it does now, and
a single letter bound to a modal editor is easy to hit by accident.

* polish(ui): close the footer waveform strip's open left edge

The strip carries only top and bottom rules -- side borders were dropped so
the canvas would land exactly on the lane waveforms' left edge -- which left
its left end open, the two rules stopping in mid-air.

Drawn as an outset box-shadow rather than a border-left: a border sits inside
the box and would push the canvas a pixel off the alignment it exists to
keep. The line falls on the same x as the stems panel's right border, so that
seam now runs unbroken from the top of the mixer to the bottom of the strip.

* fix(ui): ticking an export option no longer closes the export menu

Every interactive element in the export menu called stopPropagation so the
document-level dismiss handler would not fire, but the two option checkboxes
had no click handler at all -- so ticking one bubbled out and closed the menu
under the pointer.

That was survivable with one checkbox. This branch adds a second ("Add
count-in"), and wanting both is the normal case for practising to a click:
the first tick closed the menu, and the second needed it reopened.

Guard the panel itself rather than adding a third per-element stopPropagation
that the next option added would forget: a click inside a menu is not a click
away from it. Nothing depended on the bubble to close the menu -- the export
actions close it themselves through enterBusy() -> closePanel().

---------

Co-authored-by: Thales <>
2026-08-16 19:15:52 +01:00
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