93 Commits

Author SHA1 Message Date
Thales 2d5c12ca30 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.
2026-08-23 19:48:16 +01:00
Thales cde9e6b127 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.
2026-08-23 19:33:45 +01:00
Thales 8238eb02a7 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.
2026-08-23 19:19:04 +01:00
Thales 8c0a96689f 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.
2026-08-23 10:07:33 +01:00
Thales c4934f868c 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.
2026-08-23 08:48:13 +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 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 <>
2026-08-22 00:05:16 +01:00
Tha.Les bf561a6c81 Lead/backing vocal split, stems relocation fixes, eager model pre-download (#406)
* Add on-demand lead/backing vocal split, fix stems relocation bugs, and eager model pre-download (#275, #403)

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

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

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

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

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

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

* chore: pin Unraid template to 0.12.0

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

---------

Co-authored-by: Thales <>
2026-08-21 17:43:03 +01:00
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 <>
2026-08-21 01:00:21 +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 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 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 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 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 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 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.
2026-08-08 21:45:19 +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
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 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 <>
2026-07-17 16:04:53 +01:00
Tha.Les 9e907030e6 feat(settings): add "Reset app data" (desktop, #312) (#313)
A user reported that old work sessions kept reappearing across fresh
package installs even after deleting "the data folder". Root cause:
the real persisted state lives in ~/Documents/StemDeck/ (job data +
registry.json, and separately user-data.json for the library index),
not the extracted package's own bundled data/ folder -- so deleting or
replacing the executable never touches it.

app/core/registry.py: reset_all(jobs_dir) clears the in-memory
registry and deletes every entry under jobs_dir (job dirs, the failed/
quarantine, registry.json itself).

app/main.py: POST /api/reset, gated server-side by STEMDECK_DESKTOP=1
(not just hidden in the UI -- wiping JOBS_DIR on a shared server would
delete every user's data, not just the caller's). 409s if a job is
actively running rather than corrupting it mid-separation.

desktop/src-tauri/src/main.rs: new reset_user_data command clears the
persistent library-index store (user-data.json) -- a separate store
from job data holding folders/tracks/per-job mixer state/trash, with
no fixed key list to enumerate individually. Verified via WSL cargo
clippy + cargo test (no local Rust build in CI).

static/js/catalog.js + daw.css: Settings -> General -> a desktop-only
"Danger zone" section with a type-to-confirm dialog (must type
"RESET"). On confirm: POST /api/reset, then reset_user_data, then
localStorage.clear(), then reload -- every in-memory JS structure
re-initializes from empty instead of trying to reconcile piecemeal.

Closes #312

Co-authored-by: Thales <>
2026-07-17 15:11:27 +01:00
Tha.Les 68c449db3c feat(settings): separation quality (--shifts) setting (#308)
Adds a "Standard" / "Best (2x slower)" separation quality setting,
following the demucs_device runtime-settings pattern exactly
(app/core/settings.py get/set + env seed, app/main.py payload + POST
handler with 422 on an invalid choice).

"Best" appends --shifts 2 to the demucs invocation: separation runs
twice on a randomly time-shifted copy of the input and averages the
two passes -- measurably cleaner stems, ~2x the separation time.
Applies on any device; a CPU user who opts in accepts the wait
knowingly.

Settings UI: new select next to Compute device on the General tab,
wired the same way as the export sample rate / video height selects.

Co-authored-by: Thales <>
2026-07-17 12:19:33 +01:00
Tha.Les 0a1baa7aa6 feat(settings): add read-only Registry tab (#304)
Adds a Registry tab to Settings showing the persisted job registry
(registry.json) in a read-only viewer, so the on-disk state can be
inspected without leaving the app. Backed by a new read-only
GET /api/registry endpoint.

Closes #303

Co-authored-by: Thales <>
2026-07-17 11:35:30 +01:00
Tha.Les 378c64fbc4 feat(pipeline): quarantine failed jobs with evidence; classify causes; stage timings (#296)
The error path destroyed all evidence: rmtree on failure threw away the
demucs stderr, the stage, and the device, leaving "Audio processing
failed" as the only artifact -- undebuggable after the fact.

- Failed jobs now move to jobs/failed/<id> with an error.txt recording
  stage, device, model, classified cause, stage timings, and the demucs
  stderr tail. Heavy payloads (source, stems, video) are stripped first
  so quarantines stay KB-scale. Expired after 7 days by a new sweep that
  runs even on persistent-library deployments (failure evidence is
  diagnostics, not library content). The TTL sweep skips failed/.

- New app/pipeline/errors.py: SeparationError carries the stderr tail +
  device out of separate(); classify_failure() maps failure text to
  out-of-memory / unsupported-device / disk-full / bad-input / unknown.
  The classified cause surfaces as Job.error_detail, shown in the studio
  as a muted secondary line under the generic error message.

- Per-stage wall-clock timings (download/prepare, analyze, separate,
  post) recorded on the job, written to metadata.json, included in
  error.txt, and emitted as a one-line completion summary with the
  compute device -- performance regressions and the CPU-vs-GPU question
  are now answerable from logs.

Closes #277
Closes #294
Closes #293

Co-authored-by: Thales <>
2026-07-17 01:18:24 +01:00
Tha.Les 3359ed070a feat(settings): export sample rate option + reorganize settings tabs (#270)
* feat(settings): export sample rate option + reorganize settings tabs

Add a configurable export sample rate for mix/region downloads (WAV/FLAC/
MP3), addressing hardware samplers (e.g. Akai MPC) that reject 44.1 kHz.
The rate is a runtime setting read live by the mixdown endpoint, applied
via ffmpeg -ar; default 44.1 kHz (the stem rate) is a no-op.

Reorganize the Settings dialog into General / Network / Export tabs:
- General: max track length, compute device, out-of-sync tracks
- Network: availability toggle + QR, Port (moved here)
- Export: sample rate, MP4 video quality (moved here)

Also:
- Port field now shows the live serving port, not the stale saved
  preference (editing still saves the preference for next restart).
- In server mode the network toggle renders on + read-only, with an
  inline note explaining it is governed by server configuration.

* fix(settings): keep the dialog a uniform size across tabs

Pin the settings dialog to a fixed height and let every pane fill it
(flex:1), so switching between General / Network / Export no longer
resizes the dialog. The General pane scrolls within the fixed area.

Refs #271
2026-07-16 15:33:44 +01:00
Tha.Les c19d67eb79 fix(desktop): NVIDIA build silently falling back to CPU (#247) (#267)
* fix(desktop): NVIDIA build silently falling back to CPU (#247)

Three independent defects each land the NVIDIA build on CPU with no visible
error and no recovery path:

1. The cpu-only marker was trusted in the shared per-user data dir, not just
   the app root. The CPU build wrote/migrated that marker there, so anyone who
   ever ran the CPU build got the NVIDIA build permanently pinned to CPU --
   GPU detection never even ran. is_cpu_only_package now checks the app root
   only; a stale data-dir marker is auto-deleted and logged.

2. A CPU result from a transient failure (no GPU detected, CUDA verify
   failed) was persisted the same as a real CPU-only package, and the setup
   gate treated any truthy torchDevice as "done" -- one bad first run pinned
   CPU forever. Device selection now persists a reason (torchDeviceReason),
   and the setup gate only treats cuda/mps or a genuine cpu-only package as
   settled; a failure-born CPU or a legacy install with no reason re-probes
   the GPU on the next launch. Existing affected installs self-heal on
   relaunch, no user action needed.

3. nvidia-smi discovery only checked System32 and PATH; some DCH driver
   installs place it only under DriverStore\FileRepository\nv*\. Added that
   scan (newest package wins) and raised the first probe's timeout to 30s for
   Optimus laptops waking a sleeping dGPU. Every detection decision is now
   logged to setup.log.

Also drops the Windows CPU-only portable package's data\cpu-only staging
(scripts/windows/make-portable.ps1), which was the source of the poisoned
marker.

5 new Rust unit tests cover marker precedence, the self-heal + log line, CPU
builds not churning their own marker, and the DriverStore newest-wins scan.

* feat(settings): compute device selector for the self-hosted server

Companion to the desktop #247 fix, for the server/Docker/Unraid path: device
selection was a frozen constant (DEMUCS_DEVICE, computed once at import), so
the only override was the STEMDECK_DEMUCS_DEVICE env var plus a restart --
invisible to Docker/Unraid users without container access.

- app/core/settings.py: demucs_device setting (auto | cuda | mps | cpu,
  default auto = hardware probe). Forcing cuda/mps verifies availability
  BEFORE persisting and rejects with a clear error otherwise -- never persist
  a device that would silently fall back later (the #247 lesson applied
  here). STEMDECK_DEMUCS_DEVICE seeds the default so existing env-based
  deployments keep their forced device.
- app/core/config.py: _detect_device -> detect_torch_device (pure hardware
  probe; env handling moved to the settings seed); DEMUCS_DEVICE constant
  removed.
- app/pipeline/separate.py: reads the device fresh per job -- a Settings
  change applies to the next separation, no restart.
- app/main.py: /api/settings gains demucs_device (choice) and
  demucs_device_resolved (what jobs will run on); POST validates via the
  setter (422 with the reason). Startup log and /api/health read live.
- static/js/catalog.js: "Compute device" select in Settings -> Advanced,
  showing the resolved device; a rejected force surfaces the server's reason
  via showError and reverts the select. Also aligns the port-input fallback
  with the 8000 default from the earlier port unification.
- .docs/improvements/self-hosted-compute-device-setting.md: design doc.

5 new tests: auto-resolution, env seeding, verify-before-persist rejection,
unknown-choice rejection, and the API round trip incl. 422 paths.

* feat(settings): gray out compute devices this machine can't use

The Compute device dropdown now disables options that aren't available or
detected (Auto and CPU are always selectable; CUDA/MPS depend on the
hardware + torch build), labeling them "— not available" so it's clear why.

- config.py: available_torch_devices() returns the usable devices best-first;
  detect_torch_device() is now its first element (no duplicated torch probe).
- settings.py: set_demucs_device verifies against membership in
  available_torch_devices() rather than only the top pick.
- /api/settings: new demucs_devices_available list for the UI.
- catalog.js: disable + relabel unavailable <option>s on load and after each
  change.

* fix(ui): settings scrollbar no longer overlaps right-aligned controls

The Advanced settings pane scrolls, and its scrollbar drew directly over the
right-aligned Port / Compute device controls. Reserve a scrollbar gutter
(padding-right + equal negative margin so it sits in the card's existing 12px
padding), keeping content aligned with the fixed header/footer. Surfaced once
the new Compute device row made the pane tall enough to scroll.
2026-07-15 20:47:19 +01:00
Tha.Les a857f60fbf feat(player): stream stems on desktop via the 5s-chunk engine (#261) (#264)
* feat(player): stream stems on desktop via the 5s-chunk engine (#261)

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

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

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

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

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

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

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

pip-audit newly flags torch 2.6.0 for PYSEC-2026-2286 (torch.load weights_only
deserialization -> arbitrary code execution, HIGH; fixed in 2.10.0). The exploit
requires an attacker-controlled .pth checkpoint. StemDeck never calls torch.load
on untrusted input: demucs loads only its official model weights from the
trusted torch-hub source, and users submit audio, not checkpoints. torch is
pinned <2.7 (torchaudio 2.7+ dropped the writer demucs needs), so 2.10.0 is not
adoptable yet. Documented alongside the existing ignored torch advisories.
2026-07-13 18:02:31 +01:00
Tha.Les d686e0fa45 feat(library): add More Notes Less Talk to We Recommend (#262)
Adds the YouTube channel https://www.youtube.com/@morenoteslesstalk to the
Our Friends / We Recommend list. No logo bundled yet, so it renders with the
monogram fallback until an image is added under static/img/friends/.
2026-07-13 14:06:20 +01:00
Tha.Les 2ea6748bb3 feat(player): exact timestamp input for loop start/end (#246) (#252)
* feat(player): exact timestamp input for loop start/end (#246)

Add two editable timestamp fields in the transport footer for setting the
loop region precisely, alongside the existing drag/click select. Fields
display mm:ss.mmm and accept either mm:ss.mmm or plain decimal seconds.

- utils.js: fmtTimeMs (integer-ms math, no rounding carry) and parseTimecode
  (mm:ss.mmm or plain seconds, null on invalid).
- transport.js: syncLoopInputs keeps the fields in sync on drag/toggle
  (never clobbering a field being edited, disabled when no track loaded);
  commitLoopInput parses, clamps to [0, totalDuration], enforces the
  MIN_LOOP_SEC ordering, then updates the loop via the existing setters +
  updateLoopRegionVisual. Enter/blur commit, Escape reverts. Invalid input
  reverts the field in place (showError belongs to the import form).
- player.js: refresh loop UI on track load so the inputs enable + reset once
  the duration is known.

Values flow through the existing loopStart/loopEnd setters and
audioEngine.setLoop, so the model and engine are unchanged.

* fix(player): place loop time inputs right of the loop button

Move the exact loop start/end fields inline into .footer-transport, directly
after the loop button, instead of a separate row below the time readout.
Drop the redundant LOOP label now that the fields sit next to the loop control.
2026-07-10 20:41:58 +01:00
Thales Pereira 9dec0e146b fix(mobile): tape-effect fallback for speed on LAN HTTP (no AudioWorklet) 2026-06-30 12:08:15 +01:00
Tha.Les c603c596ae fix(mobile): fast track load + pitch-preserving speed on chunked engine (#242)
Three changes to chunkedAudioEngine.js:

- CHUNK_SEC 10 -> 5: halves the initial chunk download (~10 MB -> ~5 MB for 6
  stems), reducing the first-play buffering time.

- ready() no longer blocks on chunk 0 download. It resolves after the parallel
  WAV header fetches (~6 x 1 KB) and kicks chunk 0/1 off in the background.
  play() already handled the not-yet-cached case, so first play is gapless
  once the background fetch finishes. Track appears ready in the UI in ~100 ms
  instead of several seconds.

- Add SoundTouch WSOLA AudioWorklet on the master bus (same pattern as
  audioEngine.js) and expose setPlaybackRate(). The mobile speed slider was
  already wired to call this method, but it was missing from the API so the
  control silently did nothing. Pitch is now preserved at all speeds on mobile.
2026-06-30 12:03:20 +01:00
Tha.Les 2a40dc504e feat(player): playback speed control with pitch preservation (#241)
* feat(player): playback speed control (0.5x to 2.0x)

Adds a speed slider to the transport footer (desktop) and mixer tab
(mobile) so users can slow down or speed up tracks for practice.

- audioEngine: store _playbackRate, apply to new AudioBufferSourceNodes
  on startSources(), and fix getCurrentTime() to account for rate so
  the waveform playhead and loop detection stay accurate at non-1x speeds
- transport: applySpeed() propagates rate to both engine and streaming
  paths; scroll-wheel support (+-0.25 per tick); double-click resets to 1x
- state: playbackSpeed variable + setter; speedEl/speedLabelEl DOM refs
- player: resetSpeed() called in destroyPlayer() so a new track always
  starts at 1x
- mobile: speed slider in mixer transport section, state.speed reset on
  track open

* fix(player): move speed control below play button, centered

* fix(player): tempo bar full-width below transport, TEMPO label + gold slider

* fix(player): center 1.0x on tempo slider (range 0-2, midpoint = 1.0)

* feat(audio): pitch-preserving tempo via SoundTouch AudioWorklet

Voices and instruments no longer pitch-shift when changing playback speed.
A WSOLA time-stretcher runs as an AudioWorkletProcessor on the master bus
so a single node handles all stems. Falls back to tape-effect if the worklet
API is unavailable.

* fix(audio): close array literal in Promise.all ([]) was missing ]
2026-06-30 11:43:50 +01:00
Tha.Les 32bdc38180 feat(settings): QR codes for network access (#238)
* feat(settings): QR codes for network access addresses

When server mode is on, show a scannable QR code for each local IP in
the desktop settings panel. Each QR encodes http://{ip}:{port}/mobile/
so the phone camera opens the mobile UI directly.

- Add segno (pure Python, no PIL) as a new dependency
- Add GET /api/qr?url=... endpoint that returns an SVG QR code
- Render one QR card per LAN address in the network settings section

* feat(settings): remove IP list, blur QR codes with tap-to-reveal

- Drop the yellow IP address chips; the QR label already shows the URL
- QR codes start blurred so a nearby camera app can't scan them
  immediately; tap any card to toggle the blur
- Add a hint line: "Blurred so your camera doesn't get too excited. Tap to reveal."

* fix(settings): increase gap between QR cards

* fix(settings): clip QR blur bleed with overflow hidden wrapper

* fix(settings): accent color border on QR cards

* fix(settings): thicker accent border on QR cards

* fix(settings): box-sizing border-box on QR wrap to stop corner clipping

* fix(settings): advanced pane scrolls so Done footer stays fixed at bottom
2026-06-29 19:19:59 +01:00
Tha.Les 52197aa25c feat(mobile): chunked audio engine via WAV Range requests (#237)
Replaces full-file MP3 decode with a progressive WAV engine that fetches
stems in 10-second chunks and chains AudioBufferSourceNodes back-to-back.

- First audio after ~7 MB download (one chunk for 4 stems) vs. waiting
  for the complete file
- Peak RAM ~28 MB vs. ~420 MB for a 5-minute 4-stem track
- No track-length cap (removes the 7-14 min OOM limit)
- Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
  no streaming elements, no HTTP/1.1 connection-cap underruns
- Backend needs no changes: Starlette FileResponse handles Range requests

Closes #236
2026-06-29 17:00:20 +01:00
Tha.Les 2cb7214aea fix: server network access and YouTube Shorts support (#233)
* feat: support YouTube Shorts URLs

Normalize youtube.com/shorts/<videoId> to the standard watch?v= form
so yt-dlp receives a URL its extractor already handles.

Adds two test cases covering www. and m. variants.

* fix: allow network access by default in server/Docker mode

Two layers were blocking headless server deployments from accepting
network clients (reported in discussion #216):

1. docker-compose.yml bound to 127.0.0.1:8000 - Docker itself rejected
   connections from the network before they reached the app.

2. _default_allow_network() returned False unconditionally, so the
   network_gate middleware blocked all non-loopback requests even when
   Docker networking was configured correctly.

Fix both: bind the Docker port to 0.0.0.0 and derive the network
default from STEMDECK_DESKTOP - desktop keeps its secure off-by-default
behavior; server/Docker deployments open the gate automatically since
network access is the entire point of a headless deployment.
STEMDECK_ALLOW_NETWORK still takes precedence when set explicitly.

* style: ruff format download.py

* test: update network gate tests for server-mode default

Rename test_default_is_off to clarify it covers desktop mode (now
requires STEMDECK_DESKTOP=1). Add test_default_is_on_in_server_mode
covering the new behavior where allow_network defaults to True when
STEMDECK_DESKTOP is absent.

* fix: hide network and port settings in server/Docker mode

Network toggle and port field are desktop-only controls. In server mode
(no window.__TAURI__) the port is fixed by Docker and network access is
on by default, so exposing these controls is misleading. Hide both from
the Advanced settings tab when not running inside Tauri.

* fix: make network and port settings read-only in server/Docker mode

In server mode (no Tauri) the network toggle is always on and the port
is fixed by Docker, so both controls are shown but disabled so the user
can see the current state without being able to change them.

* fix: add read-only note to server-mode settings

Show a explanatory note at the top of the Advanced tab when running in
server mode so users know the network and port controls are intentionally
locked and where to make changes.
2026-06-28 20:59:09 +01:00
Tha.Les d9a669c85a feat: mobile UI polish + configurable port (#232)
Follow-ups to the mobile UI (#231):

- Mixer waveform now fills yellow as playback progresses (the played bars,
  not just the playhead), and repaints on seek.
- Library/Mixer/mini-player show the real YouTube/SoundCloud thumbnail when
  available (layered over the gradient as a fallback), not just a letter.
- Configurable port (Settings -> Advanced): default 8080, persisted, read by
  the desktop launcher before spawning the backend (falls back to a free port
  if taken). A stable port means a stable phone URL. Applies on restart.
- Settings General tab: number fields are digit-only text inputs (no spinner
  arrows), length-capped; max track length capped at 20 min with the limit
  noted in the description; controls aligned. Added a Done button.

Co-authored-by: Thales <>
2026-06-27 20:21:30 +01:00
Tha.Les cde1739c64 feat: mobile web UI + network access toggle (#231)
* feat: mobile web UI + network access toggle

Add a phone-optimized web UI and let other devices on the LAN reach a
StemDeck instance, so the app is usable end-to-end from a phone.

Mobile UI (static/mobile/, vanilla JS to match the stack):
- Library, Mixer, and Extract screens wired to the real API. Library lists
  /api/jobs with swipe-to-delete; Mixer reuses the desktop Web Audio engine
  (audioEngine.js, now accepting a shared gesture-unlocked AudioContext for
  iOS) with faders/mute/solo/seek, real analysis, and mixdown/MP4 export;
  Extract submits URL/upload and follows SSE progress.
- Served by a user-agent check on "/" (phones get mobile, everyone else the
  DAW; ?ui= overrides). Shared DOM-free helpers in static/js/shared/jobs.js.
- Ported from the design prototype kept under design/mobile/.

Network access (app/core/settings.py, app/main.py):
- Backend always binds 0.0.0.0; a runtime gate decides whether non-host
  requests are served (default off, opt-in). The host machine (loopback or
  its own LAN IP) is always allowed, so it can't be locked out.
- Settings dialog reorganized into General / Advanced tabs: General holds
  max track length (<=20 min) and MP4 video quality; Advanced holds the
  network toggle (with the LAN address list) and out-of-sync resync.
- Runtime settings (allow_network, max_duration_sec, video_max_height) are
  persisted and read live via GET/POST /api/settings, no restart needed.

Performance: stem MP3s are transcoded once and cached on disk (was re-encoded
on every request), so loading a track on mobile is fast and re-loads instant.

Desktop: start_backend binds 0.0.0.0; adds a local_ip command.

* chore: address code-quality bot — document suppressed excepts; untrack design refs

- _local_ips() and settings _load()/_save(): replace bare `except: pass` with
  an explanatory comment + logging.debug/warning(exc_info=True); behavior
  unchanged (still best-effort).
- _load(): handle the no-file case explicitly (FileNotFoundError) vs. logging
  genuinely corrupt files.
- Untrack design/ (the imported Claude Design prototype) and gitignore it — it's
  a local spec reference, not shipped code, and the static analyzer's "no-effect
  expression" flags on its <x-dc> template bindings were false positives.

---------

Co-authored-by: Thales <>
2026-06-27 19:04:28 +01:00
Tha.Les 7c39ad1e66 feat: add Empress Effects to We Recommend (+ fix Thomann description) (#230)
* feat: add Empress Effects to We Recommend

Effects-pedal maker; links to empresseffects.com (website, so logo style /
no Instagram glyph, like Lisbon Guitar Works). Image can be dropped at
static/img/friends/empress-effects.png later; shows the monogram until then.

* fix: correct Thomann description to "Online Music Store"

---------

Co-authored-by: Thales <>
2026-06-26 16:04:00 +01:00
Tha.Les 9ca03fc4d1 chore: MP4 wording cleanup + "We Recommend" rename/polish (#228)
* chore: drop "karaoke" wording from the MP4 export

The video export is just an MP4 export, not specifically a karaoke
feature. Replace all "karaoke" references in UI strings, the download
filename, comments, docstrings, and docs with neutral MP4/video wording.
No behavior change.

- UI: MP4 "Export Mix" subtitle -> "Export mix with the original video".
- Download filename: <title>_karaoke.mp4 -> <title>_video.mp4 (frontend
  download attr and backend Content-Disposition).
- Comments / docstrings / README updated; no renamed identifiers
  (downloadCurrentVideo, /video.mp4, has_video were already neutral).

* chore: rename "Supporters" UI label to "We Recommend"

Match the README "We Recommend" section. "Supporters" implied a
sponsorship relationship the project explicitly does not have (no money
or funding accepted); these are editorial recommendations of makers and
artists. Updates the rail button (title/aria-label/chip) and the dialog
heading. Internal ids stay friendsBtn/friendsTitle.

* feat: polish "We Recommend" and add Thomann + Analog4Lyfe

- Stack the rail label onto two centered lines ("We" / "Recommend") so it
  no longer clips the 40px chip, and swap the TV icon for a heart (both the
  rail button and the dialog header).
- Add a monogram avatar fallback: tiles with no image (or a broken image)
  render an on-brand circular initial instead of a broken-image icon.
- Add two recommendations: Thomann (@thomann.music) and Analog4Lyfe
  (@analog4lyfe), in the dialog grid and the README table. Their images
  (static/img/friends/{thomann,analog4lyfe}.jpg) can be dropped in later;
  until then they show the monogram fallback.

---------

Co-authored-by: Thales <>
2026-06-26 12:15:46 +01:00
Tha.Les da93c5ee44 feat: export as MP4 (karaoke video) for MP4 uploads and YouTube (#226)
* feat: export as MP4 (karaoke video) for MP4 uploads and YouTube (#219)

Add an MP4 export that muxes the current mixer state (e.g. vocals muted)
with the source video, producing a karaoke-style video.

Backend:
- Preserve a silent video.mp4 from .mp4 uploads (stream-copy, no re-encode).
- YouTube jobs do a best-effort video-only download (H.264/avc1, <=720p)
  to video.mp4, decoupled from the audio source so failures degrade to
  audio-only. New STEMDECK_VIDEO_MAX_HEIGHT config.
- GET /api/jobs/{id}/video.mp4 streams a fragmented MP4: the amix audio
  graph encoded as AAC, video stream-copied.
- has_video flag on Job, surfaced in state and persisted to metadata.

Frontend:
- MP4 added as a fourth export format (WAV/MP3/FLAC/MP4), shown only for
  jobs with a preserved video track. In MP4 mode, Export Mix produces the
  karaoke video and the audio-only Stems/Region rows are hidden.

SoundCloud and plain audio uploads are audio-only (no MP4 option).

* feat: bundle FFmpeg on Linux via first-launch download

Linux no longer requires `sudo apt install ffmpeg`. The desktop shell now
downloads a static FFmpeg build into the user data dir on first launch
(like Windows/macOS), falling back to a system ffmpeg on PATH when present.
This also fixes Demucs failing to decode compressed sources, since the
download lands in data_dir/ffmpeg which config.json already adds to PATH.

- ensure_ffmpeg: prefer a system ffmpeg, else download_linux_ffmpeg.
- download_linux_ffmpeg: fetch the .tar.xz, extract with system tar,
  copy ffmpeg + ffprobe into data_dir/ffmpeg. STEMDECK_FFMPEG_URL overrides.
- Widen download_file and make_executable from macos to unix so Linux
  reuses them.
- Not bundled in the tarball, so we don't redistribute FFmpeg.
- Update Linux README/notices/packaging comment to drop the ffmpeg apt step.

* style: apply ruff format to MP4 export code

---------

Co-authored-by: Thales <>
2026-06-25 22:46:23 +01:00
Tha.Les e817fa7839 feat: add MP4 and M4A upload support, raise limit to 400 MB (#210)
Closes #209
2026-06-18 09:23:27 +01:00
Tha.Les 907ae7f956 feat: expand Supporters (Joao, Kris), Instagram links/avatars, and README We Recommend (#202)
Adds Joao Gaspar and Kris Luthier (with bundled Instagram profile images) to the Supporters dialog; points Dlima Guitars at Instagram. Tiles gain optional role lines, round avatars for IG photos, a small Instagram glyph on IG-linked tiles, and a masonry/tilted layout. Warmer dialog tagline. README gains a We Recommend section with a no-funding disclaimer, and the old donation line now points to it.
2026-06-09 13:33:06 +01:00
Tha.Les 86f690da62 feat: add Joao Gaspar and Kris Luthier to Supporters, masonry tile layout (#200)
Adds Joao Gaspar and Kris Luthier (with locally bundled IG profile images) to the Supporters dialog. Tiles gain an optional role line, render gracefully without a logo, and lay out as independent masonry columns with a slight per-tile tilt (frames-on-a-wall look) that straightens on hover.
2026-06-09 11:55:23 +01:00
Tha.Les 64dc5f7a6d feat: Supporters dialog behind a TV rail icon (#197)
A TV icon in the sidebar rail (between Settings and Help) opens an About-style 'Supporters' dialog with partner tiles (Dlima Guitars, Lisbon Guitar Works), clickable to their sites. Logos bundled; rail widened 56->66px so the label fits. Verified headless.
2026-06-06 00:51:23 +01:00
Tha.Les a74ae01404 fix: overview waveforms - align to lanes and only show extracted stems (#196)
Subset extractions misaligned the waveform overlay and (after the first attempt) showed non-extracted stems. Render one row per mixer lane for alignment, and draw bars only for the extracted/selected stems (plus original); other lanes stay empty. Verified headless.
2026-06-05 23:34:06 +01:00
Tha.Les 23a3a3d930 fix: refresh runtime pack on app upgrade + stop false update banner (#195)
On a DMG upgrade the old runtime was kept because setup.js checked the runtime version after the 'runtime ready' early-return (dead since #779a2e6). Move the check before the early-return and treat unknown installed versions as a mismatch, so upgrades re-download the new runtime (backend+frontend). Also compare update-banner versions canonically (PEP440 vs tag) so a current app is not nagged.
2026-06-05 18:23:34 +01:00
Tha.Les 0a67593ad4 feat: add FLAC support (import and export) (#flac) (#194) 2026-06-05 15:36:49 +01:00