Commit Graph

253 Commits

Author SHA1 Message Date
Thales c218c9dd0e Relock yt-dlp to 2026.8.19 to fix YouTube 403 on download
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.
v0.11.2
2026-08-20 15:39:27 +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
Tha.Les 5565b216ba feat(linux): add an optional installer for desktop integration (#364)
Implements #342. StemDeck stays portable: extract the tarball, run
./StemDeck, and none of this is required. install.sh is there for people
who would rather launch from their applications menu.

It installs the package it sits in and never downloads anything, so the
version and the CPU/NVIDIA variant come from the package itself
(backend/static/version.json and the cpu-only marker) and cannot drift
from the build being installed. That also removes any need to verify a
second download.

Design notes, mostly things the reference installer in #342 got wrong:

- Install is atomic. The new copy goes to <target>.new and is verified
  before the old one is moved aside, so a failure partway leaves the
  working install untouched. Removing the old copy first is what made a
  failed upgrade in that fork leave the machine with no StemDeck, no
  launcher and no manifest recording where it had been.
- A failed copy cleans up its own staging directory rather than leaving
  a package-sized partial on disk.
- Exec is quoted, so an install path containing a space still launches.
- Version comparison is semver-aware. sort -V ranks 0.8.0-alpha.17 above
  0.8.0, which would tell every pre-release user they were current the
  day a stable release shipped.
- Reading a missing manifest key yields empty rather than killing the
  script, which under set -euo pipefail is what a grep|head|cut pipeline
  does.
- Global installs put the launcher in /usr/share/applications and the
  icon in /usr/share/pixmaps, so other users on the machine can see it.
- Installing from inside the destination is refused rather than moving
  the running script out from under bash.
- Non-x86_64 machines get a clear refusal instead of a binary that
  cannot run.

User data is never touched. Stems live in ~/Documents/StemDeck and the
runtime, models and logs in $XDG_DATA_HOME/stemdeck, both outside the
install directory. Legacy data/ from pre-migration builds is carried
across an upgrade, and uninstall refuses to delete it, leaving the
folder and saying why.

tests/linux/test_install_sh.sh runs the real installer against a
synthetic package in a throwaway HOME: 52 checks covering install,
upgrade, the failed-upgrade case, uninstall, corrupt manifests, paths
with spaces, legacy data, self-install, arch refusal and the semver
table. CI runs it on Linux with shellcheck and desktop-file-validate.

Closes #361
2026-08-12 19:08:49 +01:00
Tha.Les f9d7182f4b build(linux): stage desktop-entry assets into the portable tarball (#363)
Prep for the optional Linux installer (#342). Carrying the icon and a
.desktop template inside the package is what lets the installer be
self-contained: no second download, and no asset URL that can drift from
the release being installed.

The Tauri icon is already square at 1024x1024, so it doubles as the
desktop icon with no separate artwork to keep in sync.

Exec= is quoted in the template. The freedesktop spec splits Exec on
whitespace, so the unquoted form used by the reference installer in #342
produces an entry that tries to run ".../My" when the user installs to
a path like ~/My Apps. It is invisible until someone picks a custom
directory, which is why it is pinned by a test.

Both variants pick this up: the CPU and NVIDIA packages run the same
script with CPU_ONLY toggled.

install.sh itself, and the README-LINUX.txt change documenting it, land
with #361 -- this commit deliberately ships nothing that references a
file which does not exist yet.

Closes #360
2026-08-12 18:54:15 +01:00
Tha.Les 8d670e3b69 fix(player): read the whole WAV header, and say so when a track cannot load (#362)
* fix(player): read past the first 1 KB when locating the WAV data chunk

The chunked engine parses WAV containers itself and asked only for bytes
0-1023 when looking for the `data` chunk. RIFF is a linked list, so
anything the writer puts in front of `data` -- a LIST/INFO block, a JUNK
chunk padded for sector alignment -- pushes it out of that window. The
parser returned null, the engine reported a duration of 0, and playback
was disabled. The track rendered normally and the header showed its real
length, so it looked like a GPU or renderer fault rather than a parse
failure (#343).

Walk the chunk table properly and widen the request when it runs past
what was fetched, capped at 1 MB and 5 attempts. The parser reports
"need more bytes" separately from "not a WAV", since only the caller
knows whether more bytes can be had.

Two further container cases fixed along the way:

- WAVE_FORMAT_EXTENSIBLE carries the real format code in its SubFormat
  GUID. Without reading it, a float32 file parsed cleanly and then
  decoded to silence.
- A `data` size of 0 or 0xffffffff, written by encoders that stream to a
  non-seekable target and never patch the length, gave a duration of 0
  or 24347 seconds respectively. Clamp to the real length reported in
  Content-Range.

Adds tests/js/wav-header.test.mjs, which drives the real engine against
synthetic layouts through a Range-honouring fetch stub. Against the
pre-fix engine it reports 25/38, with JUNK 4096, LIST 2 KB, JUNK 300 KB
and both unpatched data sizes failing. CI runs it alongside node --check.

Closes #358

* fix(player): tell the user when a track's audio fails to load

A track whose stems could not be loaded left the studio looking normal
and said nothing. The only trace was a console warning, which in a
release desktop build has no reachable devtools, so the failure was
invisible to the user and undiagnosable from a bug report. Working out
why #343 could not play took a screenshot and a round trip for a hexdump.

Both engines now record why ready() resolved false and expose it via
getLoadError(), separating a stem that could not be fetched from one
that could not be parsed or decoded -- those send the user somewhere
completely different. The player puts that message in the error box
above the track header.

Playback errors reuse the import error box, so they are tagged: the
player retracts its own message when another track loads, without
wiping an import failure the user has not read yet. Nothing cleared
that box on track switch before.

The player also now retries with the full-decode engine when the
chunked one cannot read a container, under the same RAM ceiling the
missing-peaks swap uses. The browser's own decoder handles layouts the
hand-rolled parser may not, so this turns "playback disabled" into
"playback works" for the whole class of container problems behind #343.

Closes #359

* fix(player): reject sample formats the chunked engine cannot decode

_pcmToAudioBuffer only handles 16-bit PCM and 32-bit float, but the
header parser accepted any depth. A 24-bit or 32-bit-integer file
therefore measured correctly, reported ready, and then decoded to
nothing on every chunk.

That is worse than failing outright. An all-empty chunk result is
treated as a transient network failure and evicted from the cache, so
the scheduler retries it on the next animation frame, forever, with the
playhead pinned at zero and no message on screen. Measured against a
synthetic 24-bit file with playback running: 82 range requests in 700 ms
(~117/sec) versus 3 for a healthy file.

Reject those formats at parse time instead. The engine then reports a
readable reason and the player hands the file to the full-decode
engine, whose decoder handles 24-bit and integer PCM -- so these files
now play instead of hanging. Verified end to end with a real
ffmpeg-produced 24-bit stem: the chunked engine declines it, the
fallback picks it up, the transport advances, and playback issues no
further range requests.

Also covers WAVE_FORMAT_EXTENSIBLE float32, which is only accepted
because the real format code is read out of the SubFormat GUID; without
that it reads as 0xfffe and is now correctly rejected rather than
silently decoding to nothing.
2026-08-12 18:53:50 +01:00
Tha.Les 2f123ee973 fix(desktop): stop hoarding installed runtime archives (#357)
The runtime archive is removed once its runtime is installed, and a version change sweeps stale downloads plus any unfinished runtime swap. Deliberately narrow: settings.json (which holds the stems location), config.json, runtime/, ffmpeg/ and models/ are left alone.

Closes #356.
2026-08-12 13:53:44 +01:00
Tha.Les 1e8610bbc5 feat(settings): choose where extracted stems are stored (#355)
Settings -> General gains a StemData location row: where extracted stems live, how much is there, and a native folder picker to change it. Changing it moves the existing library, since the registry lives in that folder and leaving it behind would strand it.

Desktop only -- Docker and Unraid get their storage from a mounted volume, and STEMDECK_JOBS_DIR still overrides everything.

Closes #354.
2026-08-12 11:25:06 +01:00
Tha.Les afe871ce81 feat: background import queue, playlist import, and queue management (#350)
Imports run through an explicit serial queue: queue several tracks, a playlist, or a folder of files and keep using StemDeck while they extract. Adds a Queue view with per-job cancel and drag-to-reorder, and a restored queue waits for the user to start it.

Closes #344, #345, #346, #347, #348, #349, #351, #352, #353.
2026-08-11 21:31:29 +01:00
Tha.Les a70e9cd53e chore(unraid): pin template to 0.8.0-alpha.17 (#341) v0.8.0-alpha.17 2026-08-09 08:49:21 +01:00
Tha.Les 41bd89d060 feat(export): name every export after its song (#340)
* feat(export): prefix exported stems with the song title

Stems exported as "bass.wav" or "vocals.wav" are ambiguous the moment they
leave the app. Dropping several songs' stems into one project folder makes
them indistinguishable and they overwrite each other.

Every stem the user receives is now named "<Song>_<stem>.<ext>":

- ZIP members, via a new prefix argument to _build_stems_zip
- Single-stem downloads, via the Content-Disposition filename
- Single-stem region trims, which keep both the song and the _region marker
- The MP3 variant of a stem

The server carries the name because Content-Disposition wins over an
<a download> attribute for same-origin requests, so setting the attribute
alone had no effect. The attribute is set too, as the fallback for any
response that does not send the header.

_safe_title is split into _title_slug, which returns "" for a title that
sanitizes to nothing, and _safe_title, which keeps the "stems" fallback for
the whole-archive filename. A per-file prefix has to be droppable, otherwise
an untitled job yields a leading underscore on every member. The slug is
restricted to [A-Za-z0-9_], so it stays safe as a ZIP member name.

Also fixes the desktop per-stem download, which routed through open_url and
handed the file to the OS handler: the stem opened in a browser or media
player and was never saved, so no filename applied at all. It now goes
through save_audio_file like every other export.

Closes #336

* fix(export): prefix the MP3 region stem download too

Missed in the previous commit: the trimmed-region branch of the MP3 stem
route still built a bare "{name}_region.mp3", so it was the one stem file
the user could receive without the song prefix.

Its ternary also had an unreachable branch. Only the trimmed case reaches
that line; the untrimmed one returns from the cached-file branch above.

* fix(export): name the mixdown after the song too

The mixdown endpoint hardcoded filename="mixdown.{ext}", so every song's
mix and every region export downloaded as "mixdown.wav". Exporting a few
songs into one folder produced mixdown.wav, mixdown(1).wav, mixdown(2).wav
-- the same collision #336 reports for stems.

Content-Disposition overrides the <a download> attribute, so the name the
frontend already built was discarded. Only desktop escaped it, because
save_audio_file uses the frontend's name rather than the header.

The video export was already doing this correctly, which left the mixdown
as the only export not named after its song.

Names mirror the frontend's: <Song>_exported_mix.<ext>, and <Song>_region.<ext>
when start/end trim to a loop region.
2026-08-09 08:43:18 +01:00
Tha.Les b610275796 fix(export): re-enable Export All Stems after an export (#337)
* fix(export): re-enable Export All Stems after an export

resetBusy() cleared aria-disabled from the mix row and re-derived the
region row via applyFormatState(), but never cleared the stems row.
flashBusy() sets the attribute on all three, so after any export the
"Export All Stems" item stayed at opacity 0.4 with pointer-events: none
for the rest of the session, across every song, until the app restarted.

Only reproducible in the desktop build. In a browser _triggerDownload()
appends an <a> and clicks it; that synthetic click bubbles to the
document handler and closes the chip panel before flashBusy() runs, so
actionItems() -- which filters on offsetParent -- returns empty and
nothing is disabled. Under Tauri invoke() returns without that click,
the panel is still open, and all three rows get disabled.

Clear all three unconditionally rather than through the
visibility-filtered actionItems(), so the set and clear paths stay
symmetric. applyFormatState() still runs last and re-derives the region
row's genuine "no loop selected" state.

Fixes #335

* fix(export): don't show "Exporting" when there is nothing to zip

downloadAllStemsZip() returns early when there is no current job or no
stems loaded, but the click handler called flashBusy() regardless, so the
button showed "Exporting..." for 1200 ms for a download that never
started.

Return false on both early exits, matching the contract downloadCurrentMix
and downloadCurrentVideo already use, and surface the same kind of
showError() the mix and region rows do.

* fix(export): track real export completion instead of a fixed timer

flashBusy() reset on a 1200 ms timer regardless of how long the export
actually took, so on a large stems zip the button reported done while
ffmpeg was still working, and a failed save reported success.

_triggerDownload() now returns the invoke() promise on desktop, where
save_audio_file resolves only after the body is streamed to a temp file
and renamed, and `true` in a browser, where an <a download> is owned by
the download manager and reports nothing back. flashBusy() waits on the
promise when there is one and falls back to the timer only when there is
genuinely no signal. A rejected invoke now surfaces the backend message
instead of silently looking like a success.

A 15-minute backstop and a generation token keep a promise that never
settles from leaving the menu disabled for the session, which is the
failure mode #335 was about.

* fix(export): dismiss export errors instead of offering "Try again"

showError() always rendered a "Try again" button that hid the error and
moved focus to the URL import field. That is the right action for an
import failure and the wrong one for an export failure, which has nothing
to do with importing a new track.

It went unnoticed because export failures never reached the error box
before: the busy state was a fixed timer that could not tell success from
failure, so a failed save looked exactly like a successful one.

showError() now takes { retry }, defaulting to the existing behaviour.
The export call sites pass retry:false and get a plain Dismiss that clears
the box without stealing focus.

* fix(settings): dismiss log-export errors instead of offering "Try again"

Same defect as the audio export rows: a failed log export is not fixed by
being sent to the URL import field.
2026-08-09 08:01:29 +01:00
Tha.Les 30788531b2 feat: click track with beat grid detection and editor (#334)
Adds a click track locked to a per-track beat grid, an editor for correcting that grid, an opt-in to include the click in exports, and a Settings > Logs tab.

Detection uses beat_this (MIT code and weights) with librosa as an offline fallback, because librosa's 120 BPM tempo prior resolves a 180 BPM track to 90 and no confidence metric catches it. Click scheduling is locked to the engine's source time domain and measured at 0.000 ms error over 70 s of continuous playback.
v0.8.0-alpha.16
2026-08-08 21:45:19 +01:00
Thales c68b7897b7 chore(unraid): pin template to 0.8.0-alpha.15 v0.8.0-alpha.15 2026-08-05 13:01:00 +01:00
Tha.Les c0e4f72169 feat: OGG and Opus support — import upload and OGG export (#331)
Import: accept .ogg (Vorbis or Opus in Ogg) and .opus uploads. The
pipeline already transcodes every local upload to 16-bit/44.1 kHz WAV
via ffmpeg before Demucs, so only the extension allow-lists change:
the API gate, the web file picker/drop validation, and the mobile
accept list (which already advertised .ogg but got a server 422).

Export: add OGG (Vorbis VBR q6, ~192 kbps — the quality tier matching
the MP3 setting) to the mixdown, region, and stems-zip endpoints plus
the export format toggle in the player.

Tests: the unsupported-extension fixtures used .ogg and now use .aiff;
new upload tests for .ogg/.opus and an ffmpeg-gated OGG zip transcode
test asserting real OggS output.

Closes #330

Co-authored-by: Thales <>
2026-08-05 12:56:47 +01:00
dependabot[bot] de4199500b chore(deps): bump docker/login-action from 4.5.1 to 4.6.0 (#329)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:06:11 +01:00
Tha.Les 655b0d0e95 fix(linux): install CUDA runtime deps with the GPU torch wheel (#324) (#325)
The Linux NVIDIA package could not start its backend at all. Setup detected
the GPU and pip-installed torch==X+cuXXX with --no-deps, mirroring Windows.
But Linux CUDA wheels do not bundle the CUDA runtime -- they dlopen
libcublas/libcudnn/... out of the nvidia-* PyPI packages at import time, and
make-portable.sh strips exactly those packages to keep the tarball under
GitHub's 2 GiB asset cap. The result was a CUDA torch with no CUDA runtime:

    ValueError: libcublas.so.*[0-9] not found in the system path

app/main.py imports torch at module scope, so this killed the backend
outright ("backend did not become healthy within 90 seconds") on every
launch, not just GPU work.

- install_cuda_torch now runs a second, dependency-resolving pip pass on
  Linux only (cuda_wheel_needs_runtime_deps). Same specs and index, without
  --no-deps/--ignore-installed, so pip sees torch as satisfied and installs
  only the missing nvidia-* wheels. Windows keeps the single --no-deps swap:
  its wheels carry the DLLs in torch/lib and pulling ~2.5 GB of nvidia-*
  there would be pure waste. macOS is untouched (MPS path).
- Restore CPU torch when verify_cuda_torch fails. Recording torchDevice=cpu
  was never enough -- an unloadable CUDA wheel stays on disk and keeps the
  backend from importing torch at all. New reason
  "cuda-verify-failed-cpu-restore-failed" when even that fails.
- Extract run_pip_install (PID tracking, 20 min timeout, stderr logging) so
  both installs and the restore share one path.
- Add the missing "Linux tar.gz" option to the bug report template, as the
  reporter noted.

Co-authored-by: Thales <>
v0.8.0-alpha.14
2026-07-26 00:19:59 +01:00
Tha.Les 387b5eb6ea chore(deps): patch yt-dlp and quinn-proto advisories (#326)
Trivy fails CI on two advisories published after main's last run; neither
is related to any code change.

- yt-dlp 2026.6.9 -> 2026.7.4 (CVE-2026-55404, HIGH). The pyproject floor is
  raised too, not just the lock: build/Dockerfile installs from the project
  constraints and would otherwise keep resolving the vulnerable version.
- quinn-proto 0.11.14 -> 0.11.16 (GHSA-4w2j-m93h-cj5j, HIGH, remote memory
  exhaustion). Transitive; cargo pulls rand 0.9 -> 0.10 in that subtree.

Verified: pytest 214 passed / 15 skipped, ruff check + format clean,
cargo test 15/15 on Linux.

Co-authored-by: Thales <>
2026-07-26 00:15:15 +01:00
dependabot[bot] 2e2ad8dd4f chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2 (#323)
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / js-syntax (push) Has been cancelled
CI / sast-bandit (push) Has been cancelled
CI / deps-audit (push) Has been cancelled
CI / trivy (push) Has been cancelled
Docker Publish / build-and-push (push) Has been cancelled
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:52:34 +01:00
Tha.Les 89717d204c fix(desktop): actually hide the mode-specific parts of the release modal (#322)
The release dialog toggles its docker-pull block (server mode) and
Download button (desktop mode) with the bare `.hidden` class. But this
page only loads variables/waves/daw.css, and the only rule that hides via
`.hidden` is the scoped `.daw .hidden` -- the dialog lives outside `.daw`,
and base.css's global `.hidden` is not loaded here. So neither inner
element was ever hidden: desktop showed an empty "UPDATE YOUR SERVER"
block, and server showed a meaningless Download button.

Add explicit `.release-docker.hidden` / `.release-card .about-link.hidden`
rules (same approach as `.about-backdrop.hidden`) so desktop hides the
docker block and server hides the Download button.
v0.8.0-alpha.13
2026-07-18 18:06:01 +01:00
Tha.Les 739128d986 feat(desktop): release-notes modal with per-arch download link (#321)
* feat(desktop): release-notes modal with per-arch download link

Clicking the "New release available" notification card now opens a
settings-style modal showing the GitHub release notes (rendered from a
minimal, XSS-safe markdown subset) and a Download button that points at
the asset matching the running build.

- New Tauri command build_target returns os/arch/gpu so the frontend can
  pick the exact release asset (macOS keys on arch; Windows/Linux add the
  .NVIDIA infix for the CUDA variant). Falls back to a navigator OS guess
  in web/server mode.
- renderReleaseNotes handles headings, bold, http(s) links, bullet lists,
  fenced code blocks, and GitHub blockquote admonitions (the macOS
  "IMPORTANT" block), escaping first and emitting only whitelisted tags.
- Modal reuses the About-dialog styling; the notification card is now
  click-to-open (badge + card on launch, modal only on click).

* chore(unraid): pin template to 0.8.0-alpha.13

* feat(desktop): show docker-pull guidance in server mode

In server/Docker mode there is no Tauri, so a per-arch desktop download
is meaningless (the client browser's OS/arch has nothing to do with the
container, and updates are done by pulling a new image). The release
modal now detects server mode and replaces the Download button with the
`docker pull ghcr.io/stemdeckapp/stemdeck:<tag>` command plus a note that
Unraid users update via the Community Applications template.

Desktop mode is unchanged (per-arch download).
2026-07-18 17:32:18 +01:00
Tha.Les 8574be99a6 build(windows): don't bundle CUDA torch in the NVIDIA package (revert #318) (#320)
#318 force-installed the CUDA torch wheel into the NVIDIA package, which
pushed the release zip to 2456 MB -- over GitHub's 2 GiB release-asset cap
(2147483648 bytes) -- so the Windows NVIDIA asset upload failed on the
v0.8.0-alpha.12 release.

Bundling was the wrong approach: the desktop app already installs the CUDA
build on first run via ensure_torch_device (which picks the cuXXX index
matching the detected GPU's compute capability / driver). The NVIDIA
package is meant to ship base torch small (~419 MB zip) and pull CUDA on
first launch. #317 is the actual fix -- it makes that first-run install
trigger again after a CPU->NVIDIA build swap.

Restore the non-CpuOnly path to base torch and document why not to bundle,
so this isn't reintroduced.

Co-authored-by: Thales <>
v0.8.0-alpha.12
2026-07-18 10:47:30 +01:00
Tha.Les cba8acc92f chore(unraid): pin template to 0.8.0-alpha.12 (#319)
Bump the Unraid Community Applications template to the 0.8.0-alpha.12
image tag. Merge only after the v0.8.0-alpha.12 release is published, as
the versioned image tag is created by the release build.

Co-authored-by: Thales <>
2026-07-18 01:15:03 +01:00
Tha.Les 9c3eedd2eb build(windows): install CUDA torch explicitly for the NVIDIA package (#316) (#318)
The non-CpuOnly path relied on `pip install .` yielding a CUDA torch, but
the Windows PyPI torch wheel is CPU-only (unlike Linux, whose default wheel
bundles CUDA). GPU support in the "NVIDIA" package was therefore an implicit
property of the build host's pip resolution and could silently regress to
CPU. Force-install the cu124 wheel from the PyTorch index so the NVIDIA
build is deterministically GPU-capable; the CPU path is likewise made
explicit so it downgrades a host that resolved a CUDA wheel.

Co-authored-by: Thales <>
2026-07-18 01:00:06 +01:00
Tha.Les 89367e5117 fix(desktop): re-detect GPU after CPU→NVIDIA build swap (#316) (#317)
A persisted "cpu-only-package" torch device reason was treated as a
permanently settled decision by the setup gate, so replacing the CPU
portable build with the NVIDIA build in the same data dir left the user
pinned to CPU -- setup skipped ensure_torch_device (and its #247
clear_stale_cpu_marker + GPU re-probe) entirely.

Invalidate the stale reason at probe time when the current install is no
longer the cpu-only package, so the device reads as unsettled and setup
re-runs GPU detection. Reuses the existing #247 self-heal path.

Co-authored-by: Thales <>
2026-07-18 00:59:49 +01:00
Tha.Les 5615fb063d chore(unraid): pin template to 0.8.0-alpha.11 (#315)
Co-authored-by: Thales <>
2026-07-17 16:51:39 +01:00
Tha.Les 0bec808ae1 feat(settings): make Reset app data available in server mode too (#314)
#313 gated the reset behind STEMDECK_DESKTOP=1, both server-side and in
the UI. Removing that restriction: it's covered by the same network_gate
middleware every other settings-mutating endpoint already relies on (host
machine always allowed, a LAN device only while network access is on) --
not a new class of risk this endpoint introduces on its own. The Danger
zone section in Settings -> General now always renders; the frontend's
Tauri-specific reset_user_data call stays conditional on window.__TAURI__
existing (desktop only, no equivalent needed in server mode since the
library index there already lives in localStorage, which the existing
localStorage.clear() call already covers).

Confirm dialog and row description now say explicitly that a reset on a
shared server affects everyone who uses it.

Co-authored-by: Thales <>
v0.8.0-alpha.11
2026-07-17 16:04:53 +01:00