main
60 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d52fdb9bdd |
A second StemDeck no longer adopts the first one's backend (#431)
Launching a second StemDeck while one was running gave the new window the already-running instance's backend, and with it that instance's data directory and library. Nothing on screen suggested anything was wrong. Two independent faults had to line up, so both are fixed. The port reservation probed 127.0.0.1 while the backend binds 0.0.0.0. On Windows those do not collide, so an occupied port looked free, the fallback to another port never ran, and the backend we spawned died on bind with 10048. The reservation now claims the same address the backend will bind, so a taken port reads as taken. It claims it without listening. bind is what reserves an address; listen is what makes a program a server, and a server on 0.0.0.0 is what makes Windows Firewall interrupt the user. The shell should not be answering that prompt on the backend's behalf, so the reservation binds only. The health check accepted any 200 on the port. That is what turned a dead child into a silent adoption: the other instance answered instantly while ours was still starting. /api/health now reports the answering process, and only the child we spawned is accepted. The child is watched while polling too, so a backend that cannot bind fails in a second with a message naming the contended port, rather than after ninety with a stack trace. Verified by removing the identity check and confirming the scenario test fails without it. Co-authored-by: Thales <> |
||
|
|
b9437115ca |
Only offer an update once the release has been promoted (#429)
Two halves of the same bug. The upload steps passed no `prerelease` to softprops/action-gh-release, which defaults it to false and writes it back. Attaching assets therefore promoted the release it was attaching them to. v0.13.0 and v0.14.0 were both published as pre-releases and both ended up marked as the latest release within the hour, which also fired `released` and pushed :latest to GHCR. Each workflow now carries the release's own flag. The updater took the newest non-draft release, pre-releases included. That was deliberate when it was written, because /releases/latest hides pre-releases and every release was one, so tracking stable meant nobody would ever be notified. With the flag preserved, a pre-release is now genuinely a pre-release, and offering it would push unverified builds to everyone. It now takes the newest release that is neither draft nor pre-release, so a release reaches users only once it has been promoted. The e2e stub grew an unpromoted pre-release ahead of the stable one, and the spec pins which of the two the card names. Co-authored-by: Thales <> |
||
|
|
1e248ac53e |
Preserve user settings across a new install (#425) (#427)
A portable install keeps settings.json inside its own folder, so extracting a new version to a fresh folder started with no settings at all: the relocated stems folder, port, compute device, quality and language all silently back to defaults. The shell already restored from a per-user copy; nothing had written it since the data directory moved. Adds the write half, seeded on first load so settings configured by an earlier release are carried forward too. Also pins the Unraid template at 0.14.1. |
||
|
|
9b655d5a07 |
Windows: leaner portable package and opt-in in-app updater (#421) (#423)
* feat(windows): leaner portable package and opt-in in-app updater (#421) Issue #421 asked for Python embedded in a single EXE so updating would not mean copying ~20k loose files over an existing install. A onefile EXE is not viable for this stack (onefile modes re-extract the whole multi-GB payload on every launch, and torch/onnxruntime fight frozen-import hooks), so this addresses the root cause instead: ship less, and stop making users hand-copy a full zip for a release that only changed app code. Leaner package (make-portable.ps1): - Stripping is now unconditional. The -StripVenv opt-in gate was a silent regression risk: nothing stopped a future workflow edit from shipping the unstripped venv with no error. - Also strips stdlib base/Lib/test and per-package test/tests dirs. - Deliberately does NOT strip .dist-info/RECORD. pip needs it to replace a package, and install_cuda_torch pip-installs into this venv on every NVIDIA machine at first run; removing it yields "Failed to uninstall ... missing RECORD file". - Adds a post-strip import check so an over-aggressive strip fails the build rather than a release. Updater (main.rs, catalog.js): - New commands installed_runtime_id, download_app_update, apply_app_update. - Opt-in: the check on launch is unchanged, but download and apply are each an explicit click. It never auto-applies and never interrupts a running job. - Replaces StemDeck.exe and backend/ only. python/ is never touched, because an NVIDIA install rewrites it with CUDA torch at first run and torchDeviceSettled skips ensure_torch_device once the device is cuda, so swapping the directory would silently drop that machine to CPU with no recovery. - The runtime id (uv.lock + interpreter major.minor) is a compatibility gate, not a download trigger: if a release changed the Python dependency set the updater stands down and points at the full download. Only 19 of the last 200 commits touch uv.lock, so the fast path covers most releases. - apply_app_update stages and validates everything before any destructive rename, stops the backend synchronously first (the existing stop_backend returns before the process dies, which would have made every update fail on Windows), and retries renames past transient AV/indexer handles. - Known gap, documented in code: the two exe renames are not atomic. A hard crash in that window leaves StemDeck.exe.old needing a manual rename. Closing it needs a bootstrap launcher that is never itself replaced. CI publishes -app.zip, its .sha256 and -runtime-version.json alongside the unchanged full zips. Fresh installs are unaffected. i18n: the 5 new strings are translated into all 7 language tables, not just English. t() falls back to English silently, so an English-only key looks correct in testing and ships untranslated to six locales. Verified: Windows and Linux (WSL) both compile clean with no new clippy warnings, 39 Rust tests pass on both, JS suites pass, ruff clean. Two new unit tests pin the JSON contract between the PowerShell writer and the Rust reader. Not yet verified: no end-to-end run against a real release. * fix(updater): make the in-app update actually work, verified end to end (#421) Built both packages on a real Windows box and drove the whole flow. Four bugs that only surfaced by running it, none of which static checks could see. 1. Stale version after updating. app_version() read installed dist metadata, which lives in python/ -- the directory the updater deliberately never replaces. A self-updated install kept reporting the old version and would re-offer an update it had already applied, forever. It now prefers the app layer's static/version.json, which moves with backend/. Gitignored, so Docker and source checkouts still fall through to the hatch-vcs metadata. Proven: after a real update, python/ dist-info says 0.13.0 while /api/health reports 0.13.1. 2. The page CSP blocked the whole feature. The UI is served over http by the Python backend, so its connect-src applies: api.github.com is allowed, github.com and objects.githubusercontent.com are not, and that is where release assets live. Fetching the checksum and runtime id from JS was refused, so the pill would simply never appear. Those two reads moved into Rust (check_app_update), whose HTTP client is not bound by the page CSP, so the policy from #171 stays exactly as tight as it was. 3. plugin:event|listen refused by the Tauri ACL. App-defined commands are not ACL-gated but plugin commands are, and the capability does not cover the remote http origin the UI is served from. The progress bar is now indeterminate instead of granting a remote origin event permissions to put a percentage on a 5 MB download. 4. The post-strip import check re-bloated the package. Running Python regenerated 1,912 files / 39 MB of __pycache__ that the strip had just removed, cancelling nearly all of it: the net saving was 180 files. Swept once after the last interpreter run, and backend/ no longer ships a developer's local __pycache__ either. Also: the *.old sweep now runs on every launch rather than only on a version change. apply_app_update relaunches then exits, so on the first launch of the new build Windows still holds StemDeck.exe.old open, the delete fails silently, and gated on a change that already happened it would never retry. Observed for real: 15.7 MB stranded. Verified swept on the next launch. UI: "Update now" is an accent pill BESIDE Download, not a replacement, so the zip stays one click away and is the escape hatch if an update fails. Measured against the published v0.13.0 package: 18,143 -> 16,056 files (-2,087, -11.5%) and 883 -> 850 MB. The real win for #421 is the update path itself: 5 MB and 123 files instead of 284 MB and 16,056. Verified on this machine: a real 6-stem Demucs separation through the stripped package; the full notify -> Update now -> download -> restart -> relaunch cycle, after which user data (job, 7 stems, 130 MB of models), portable.txt, cpu-only and python/ were all untouched; and the safety gate correctly declining, with no download attempted, when the release's runtime id differs. Not covered: the NVIDIA package was not built, though the risk that motivated the gate is structurally gone now that python/ is never swapped. * fix: address code-quality review on the version-source change (#421) Both findings from the automated review were fair. Narrow the bare `except Exception: pass` in app_version() to (OSError, ValueError, AttributeError). That is bandit B110, which this repo's own security conventions call out. The three cover every real failure here -- absent or unreadable file, invalid JSON or bad encoding, and valid JSON that is not an object so has no .get -- while letting an actual bug in the function surface instead of silently degrading the reported version. Bandit now reports no issues for the file. Use one import style in test_health_api.py so app.main is no longer imported both as `import app.main as main` and `from app.main import app` in the same module. Also added a "[]" case: JSON that parses but is not an object, which is the AttributeError branch the narrowed except now names explicitly. * feat(updater): extend the in-app update to Linux (#421) Linux ships the same shape as Windows -- executable, backend/ and python/ side by side -- so the updater generalises rather than needing a second design. The platform-specific parts are now three small seams: the archive format, the executable name, and one new gate. Rust: - widen the updater's cfg gates from `windows` to `any(windows, linux)`, and replace extract_zip_archive with extract_update_archive, which uses zip on Windows and the existing extract_tar_archive on Linux - APP_EXE_NAME so the swap and the leftover sweep stop hardcoding StemDeck.exe - stop_backend_and_wait now sends SIGTERM and waits before escalating on unix, matching what stop_backend already does on window close - new app_root_is_writable gate: packaging/linux/install.sh offers a global install into /opt/stemdeck, which is root-owned while the app runs as the user. check_app_update declines up front rather than failing part way through a swap. Windows portable installs are user-writable by construction, but the probe is cheap and honest on both. tar rather than zip on Linux is deliberate: it preserves the executable bit. A zip would land StemDeck without +x and the relaunch after an update would fail with a permission error. Packaging (scripts/linux/make-portable.sh): - write python/runtime-version.json using the same uv.lock + interpreter major.minor formula as the Windows script, so the compatibility gate behaves identically on both - bring the strip to parity: stdlib test/, per-package test/tests, a post-strip import check, and a final __pycache__ sweep after the last interpreter run - PUBLISH_UPDATER_ASSETS=1 emits the slim app-layer tarball, its checksum and the runtime marker; wired into the CPU build in linux-release.yml since StemDeck and backend/ are identical between both variants Frontend: updaterAssetNames() maps the platform to its asset names, and the wiring is gated on that rather than on os === "windows". macOS is deliberately still excluded, and the comments now say why rather than just that it is: backend_dir() resolves the backend inside the downloaded runtime pack rather than the .app, so its app layer is a different thing and the existing runtime-pack updater already covers most of it. Verified: both platforms compile clean with no new clippy warnings, 42 tests on Windows and 43 on Linux (the extra one is the read-only-root gate, which is meaningless on Windows). The app-layer archive was round-tripped on Linux to confirm it contains exactly StemDeck + backend/, that python/ does not leak into it, that the executable bit survives, and that replacing a running binary works. Not yet run end to end against a real Linux release. * fix(updater): see pre-releases, and compile the Rust in CI (#421) Two gaps that would each have undermined the update flow on release day. The update check polled /releases/latest, which GitHub defines as the most recent NON-PRERELEASE, non-draft release. Ship a version with the pre-release box ticked and it becomes invisible: no notification, no update button, on any platform, with nothing in the logs to explain it. StemDeck has always published even its alphas as normal releases (v0.8.0-alpha.17 has prerelease=false), which is the only reason this has not bitten yet -- it was a trap waiting on someone ticking a box. Now polls the releases list and takes the newest non-draft, so it is correct either way. Drafts stay excluded: they are already invisible unauthenticated, and a maintainer should not be offered a release whose assets do not exist yet. windows-check.yml and macos-check.yml now also run on pull requests that touch desktop/src-tauri/**, not workflow_dispatch only. This PR added roughly 600 lines of mostly cfg-gated Rust across two commits and every CI check passed without compiling a single line of it; the comment at the top of windows-check.yml notes that exact gap already shipped a broken Windows build in v0.11.1's first release attempt. Scoped by path so the self-hosted runners see no extra load from the majority of PRs, which never go near src-tauri. This also gets the macOS branch compiled for the first time. Local verification covered Windows and Linux, so the cfg(not(any(windows, linux))) arm of the three updater commands has never been near a compiler. * test(e2e): match the releases-list shape the app now polls (#421) The update-check stub returned a single release object, which was right for /releases/latest. The app now polls the releases list so a pre-release is still seen, so the fixture has to return an array or checkForUpdate bails and the release card never appears. Caught by frontend-e2e on the previous commit, which is the suite doing exactly its job: the only assertion that covers this path is report-failure.spec.mjs:98, and it went red immediately. * feat(i18n): add French, and make the runtime id line-ending independent French is a complete table, not a partial one: 435 keys, the same set German and Portuguese carry (English's 443 minus the ten Polish-only .few/.many forms and the bare upload.skippedFiles, plus singular forms for the three playlist.skip.* families). French takes the one/other buckets, so plural() needs no change. Verified with the checks from .claude/rules/i18n.md: the drift check reports clean, and separately there are zero {placeholder} mismatches and zero HTML tag mismatches against English. The 27 strings identical to English are genuinely identical in French (Piano, Solo, Transport, Position, LUFS, Standard, Port, the brand names, CUDA (NVIDIA), MPS (Apple Silicon)). Separately: the runtime id was being computed from the raw bytes of uv.lock, so a Windows checkout with core.autocrlf=true hashed CRLF and Linux hashed LF, and the same lockfile produced two different ids -- caught by building the Linux package and seeing py3.12-d74d6ef80c5e9d1f where Windows had produced py3.12-dbda45e38e1044cf. Each platform stayed self-consistent so the gate still worked, but the id would shift spuriously if a runner's autocrlf ever changed, silently declining app-only updates that were in fact compatible. Both scripts now hash the content with newlines normalised; PowerShell, bash and a reference Python implementation all agree on d74d6ef80c5e9d1f. * chore: pin Unraid template to 0.14.0 Per .claude/rules/unraid-template-version.md this is an explicit decision each time, not a default. Confirmed for this release. The 0.14.0 GHCR image is published by docker-publish.yml when the release is created, so the tag exists shortly after this lands. --------- Co-authored-by: Thales <> |
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 <>
|
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
679eb78fa1 |
perf(api): cache mixdown renders (#311)
* perf(api): cache mixdown renders (#290) Identical mixdown params re-ran the full ffmpeg graph on every request. On a shared server, repeat downloads of the same export (a common case) burned CPU for a pure function of the inputs. _stream_ffmpeg optionally tees yielded chunks to a per-request temp file as it streams; a clean finish atomically renames it into place as the cache entry and prunes the cache to a 20-file / 500 MB budget (oldest first). Any failure or client disconnect removes the temp file instead -- a render the client didn't get in full never becomes a cache hit for the next request. get_mixdown's cache key covers every render input (job_id, ext, stems, gains, region, and the live export sample rate setting), computed after the existing job/stem validation so a deleted or not-ready job still 404s the same way it always has instead of serving a stale entry. A hit returns a FileResponse with no ffmpeg invocation at all. Also: cache/ (CACHE_DIR's default under the repo root for source runs, same pattern as jobs/) wasn't gitignored -- added it alongside jobs/. * fix(api): silence bandit B324 on the cache-key sha1 (not a security use) * address code-quality review: log prune failures, unify import style - _prune_mixdown_cache: log a debug line instead of silently swallowing a failed unlink, so a stuck cache entry leaves a trace. - tests/test_stems_api.py: use "from app.api import stems as stems_mod" consistently instead of mixing it with "import app.api.stems as ...". --------- Co-authored-by: Thales <> |
||
|
|
08b6abf9c1 |
feat(pipeline): persistent demucs worker (#309) (#310)
Replaces the fresh-subprocess-per-job model with a warm worker process that loads the demucs model once and serves jobs one at a time over a stdin/stderr protocol, reusing the same process across consecutive successful jobs on the same device instead of paying spawn + import + model-load + CUDA warmup on every single job. Measured on an RTX 3080 (see #288's data): startup was 35-42% of the separate stage for a fresh worker. With reuse, a warm second job drops separate_startup from ~5s to ~0.6s and total job time from ~13.5s to ~6.7s -- roughly half, for every job after the first on a given device. app/pipeline/demucs_worker.py: the worker script (run via `python -m app.pipeline.demucs_worker <device>`). Calls the exact same demucs library functions the CLI itself calls (load_track, apply_model, save_audio, same default split/overlap/segment/clip/bit-depth) -- not a reimplementation of the audio pipeline, just the same calls made repeatedly on an already-loaded model instead of once per fresh process. Verified bit-for-bit identical output against the old subprocess-CLI path on a real track (with shifts=0, since demucs's own apply_model applies a random time-shift internally whenever shifts>=1, independent of this change -- both paths share that variance equally). app/pipeline/separate.py: _run_demucs now reuses-or-spawns a worker via _get_worker(device) instead of always spawning; dispatches one JSON line per job and reads progress from stderr exactly as before (same tqdm-driven "NN%" lines, same watchdog-stall detection). A worker is torn down -- never reused for the next job -- after a cancel or any job failure: GPU/CUDA state afterward isn't something we can vouch for, so only the happy path keeps the process warm. A device change (Settings, or the GPU->CPU fallback within one job) always gets a fresh worker. app/main.py: kill the worker on clean app shutdown so it's never left as an orphaned process. Closes #309 Co-authored-by: Thales <> |
||
|
|
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 <> |
||
|
|
5e8caeb73d |
measure(pipeline): record demucs startup cost per attempt (#307)
Records time from Popen to demucs's first progress line as job.stage_timings["separate_startup"] -- process spawn + model load, as opposed to actual separation work. Written to metadata.json and the completion summary alongside the other stage timings (#293). Measurement only: subprocess isolation (kill-on-cancel, crash containment) is a design feature we keep. Once real numbers are in from representative machines, #288 gets a decision comment -- keep the subprocess-per-job model (expected, since startup should be 5-15s of a 1-15min stage) or open a follow-up if it's a meaningful fraction of total separate time on GPU. Co-authored-by: Thales <> |
||
|
|
6eb1741362 |
perf(pipeline): single-pass streamed peaks + presence (#306)
app/pipeline/audio_stats.py: new scan_stem() does one streamed pass over a stem WAV via sf.blocks() -- [min, max] per bucket (waveform peaks) and RMS (stem presence), both from the same blocks. Constant memory: a block is a few MB even for a 20-minute stereo stem, vs. sf.read()'s full in-memory load (~420 MB for the same file, done for up to 8 files back-to-back right after Demucs has already stressed memory -- a plausible contributor to OOM failures on memory-constrained machines). collect.compute_stem_peaks now delegates to scan_stem and returns each stem's RMS from the same pass; peaks.json's format and bucketing are unchanged (floor-division chunking, matching the old implementation bucket-for-bucket -- verified by a golden test comparing against the old sf.read()-then-chunk reference). runner._run_common now derives stem_presence from that RMS map (moved out of analyze.compute_stem_presence, which is deleted along with its separate ffmpeg-downmix decode of every stem) instead of decoding each stem twice. Known, accepted delta: presence RMS is now measured over the full stem at full sample rate, vs. the old ffmpeg-downmixed mono decode capped at the first 180s. On a real 220s track this shifted some quiet-stem presence values by up to ~6 points (piano 1->6, other 12->19) -- larger than initially estimated, but a strict accuracy improvement (whole track, not a 3-minute window), not a regression. Closes #286 Closes #287 Co-authored-by: Thales <> |
||
|
|
c896b9cfae |
perf(events): SSE dirty-flag + tear-proof job serialization (#305)
Adds Job.version, bumped by _set() on every field write. The SSE stream now compares versions instead of re-serializing + string-diffing on every 0.2s tick -- idle connections drop from a full to_state()+json.dumps per tick to one int compare, eliminating ~1,000 serializations/s at the 200-connection cap. Also closes #285 for real: if job.version changes while to_state() is mid-call, the snapshot may mix pre- and post-write fields (a torn read). The stream loop now detects that (version read before vs. after serializing) and discards the snapshot instead of yielding it, retrying immediately. Already-terminal jobs (done/error/cancelled) now close the stream right after the initial snapshot instead of idling. Closes #289 Co-authored-by: Thales <> |
||
|
|
1050789b8d |
fix(desktop): watchdog shutdown must not hard-kill on Windows (#302)
The desktop parent watchdog used os.kill(os.getpid(), SIGTERM) to stop the backend, with a comment promising uvicorn's shutdown sequence would run. On Windows that call is TerminateProcess -- a hard kill that bypasses every cleanup path, so the promise only held on POSIX. signal.raise_signal(SIGTERM) triggers the in-process Python-level handler uvicorn installed, with identical semantics on both platforms. Closes #282 Co-authored-by: Thales <> |
||
|
|
666005e921 |
fix(registry): persist race on Windows; recover metadata-less done jobs (#301)
persist() is called concurrently from the pipeline thread, API threads, and the sweep loop, all sharing one temp path. Two writers could collide, and on Windows os.replace over a file another writer holds open raises an uncaught PermissionError. The write+replace now happens under the existing lock with a unique temp name per call (the _ensure_cached_mp3 pattern), best-effort like the settings store. _recover_done_job required metadata.json, which is written after status flips to done -- a crash in that window left a complete stems dir permanently unrecoverable. Such dirs now recover with a placeholder title, and a minimal metadata.json is written immediately so the next restart takes the normal path (self-healing, not a lasting special case). The stems-present requirement is unchanged. Closes #281 Closes #284 Co-authored-by: Thales <> |
||
|
|
4a3ba0f92a |
fix(download): retry the metadata probe; set socket timeouts everywhere (#300)
The pre-download metadata probe (duration check) ran outside the retry loop: a transient network blip on that single request failed the whole job immediately, even though the actual download had a 3-attempt backoff. The probe and the download now share one retry policy (_with_retries), with the same retriable/non-retriable classification, cancel translation, and user-visible "retrying" stage message. Every YoutubeDL instance (probe, audio download, video track) now sets an explicit 30 s socket_timeout so a stalled TCP connection can never hang a job indefinitely. Closes #279 Co-authored-by: Thales <> |
||
|
|
5355a93e45 |
feat(pipeline): retry separation on CPU when a GPU attempt fails (#299)
One MPS/CUDA failure (OOM, unsupported op, driver hiccup) killed the whole job with "Audio processing failed" -- the Mac Mini report verbatim, where the user needed a LaunchAgent env-var hack to force CPU. The job now retries once on CPU and completes, slower but alive. The fallback is loud, never silent (the #247 lesson applied to the runtime path): the stage line reads "GPU failed -- retrying on CPU (slower)..." while it runs, the WARNING log carries the classified cause and full stderr tail, and gpu_fallback/compute_device persist to job state and metadata. It fires even when the user forced cuda/mps in Settings -- a dead job with no diagnostics is strictly worse than a slow one that explains itself. Mechanics: separate() is now the retry-policy layer over _run_demucs() (one attempt: spawn, stream progress, stall watchdog, cancel translation) with a _demucs_cmd() seam for tests. Partial output from the failed GPU attempt is cleared before the CPU run so collect() can never pick up half-written stems; progress resets to 0 since CPU restarts from scratch. A cancel during the GPU attempt raises JobCancelled without a pointless CPU retry. If CPU also fails, the SeparationError carries both attempts' stderr tails for the quarantine. Closes #276 Co-authored-by: Thales <> |
||
|
|
a666b39497 |
fix(api): log ffmpeg stderr when a streamed render fails (#297)
Streamed ffmpeg renders (mixdown export, region trims, stem MP3, video mux) sent stderr to DEVNULL. When ffmpeg died mid-stream the client received a truncated file with HTTP 200 already committed -- and no trace of the failure existed anywhere, making "my export is broken" reports unsolvable. stderr is now drained into a bounded tail (mandatory anyway once it is a pipe -- an undrained full pipe would deadlock ffmpeg) and logged at WARNING with a per-endpoint context (job id, format, stems) when the process exits non-zero. Kills we initiated on client disconnect are expected and stay silent; EOF-then-nonzero is the failure signature, since returncode stays None until wait() even for an exited child. Closes #280 Co-authored-by: Thales <> |
||
|
|
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 <> |
||
|
|
995e402220 |
feat(logging): rotating file log + level control; stop leaking exceptions into the UI (#295)
Attach a RotatingFileHandler (LOGS_DIR/stemdeck.log, 5 MB x 3, timestamped)
to the stemdeck logger so server and Docker deployments keep an on-disk
trail -- until now LOGS_DIR existed but nothing ever wrote to it, and
stdout scrollback was the only record. Best-effort: a read-only FS
degrades to stdout-only logging instead of failing startup.
Level is now controllable: STEMDECK_LOG_LEVEL=DEBUG|INFO|WARNING, with
STEMDECK_DEBUG=1 as shorthand. This also un-deadens the analyze
diagnostics ("chroma:", "key candidates:") -- they are logger.debug
calls that could never emit under the previous hardcoded INFO level,
despite the comment claiming otherwise.
Also stop interpolating raw exception reprs into the user-visible
"Analysis skipped" stage message; the traceback is already in the log.
Closes #291
Closes #292
Closes #283
Co-authored-by: Thales <>
|
||
|
|
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 |
||
|
|
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. |
||
|
|
ce86e8ad57 |
feat(unraid): publish container to GHCR and add Community Applications app (#253)
* feat(unraid): publish container to GHCR and add Community Applications template - add docker-publish workflow: build build/Dockerfile and push ghcr.io/stemdeckapp/stemdeck on release + manual dispatch (linux/amd64) - add templates/stemdeck.xml: Unraid Docker template (port 8000, /app/jobs + /cache volumes, persistent library default, optional NVIDIA runtime vars) - add ca_profile.xml at repo root for the CA submission scan - document the GHCR image and Unraid install in README The published image keeps the default Linux x86_64 (CUDA) torch wheel, so a single image runs on CPU by default and uses the GPU when started with --runtime=nvidia; _detect_device() auto-selects CUDA. * ci(unraid): derive manual-dispatch version from git instead of 0.0.0 Drop the workflow_dispatch version input and compute it with git describe (hatch-vcs style) so manual builds carry a real dev version. Fetch full history + tags on checkout so git describe resolves. * ci(unraid): publish a rolling :edge image on merge to main Add a push trigger on main so every merge builds and pushes ghcr.io/stemdeckapp/stemdeck:edge. :edge never moves :latest, which stays reserved for stable releases. * chore(unraid): point template at :edge until a stable release exists * docs(unraid): document edge/latest/version image tags and use :edge in the run example * chore: default run.sh PORT to 8000 to match the container/Unraid port * chore: default advertised port to 8000 across backend and desktop Align DEFAULT_PORT (app/core/settings.py) and the desktop launcher's configured_port() fallback (desktop/src-tauri/src/main.rs) from 8080 to 8000 so every path -- container, run.sh, and desktop -- shares one default. Update the settings comment and the port-default test accordingly. |
||
|
|
8d816b1ad7 |
fix(server): persistent library on self-hosted web server (no TTL sweep) (#251)
The 24h job TTL sweep was only disabled under the desktop shell (STEMDECK_DESKTOP=1). Running the bare web server via run.sh left the sweep active, so it deleted processed tracks older than 24h on startup and hourly, turning saved library entries into "audio no longer available" / out-of-sync (local-file tracks can't be auto-restored). Add STEMDECK_PERSIST_LIBRARY=1 as a second opt-out in _sweep_disabled, and set it by default in run.sh so the self-hosted server behaves like the desktop app (persistent, user-managed library via Trash). Shared/Docker deployments that set neither flag keep the sweep. Overridable with STEMDECK_PERSIST_LIBRARY=0. |
||
|
|
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. |
||
|
|
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 <> |
||
|
|
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 <> |
||
|
|
cbc64fdfc1 |
fix: don't auto-purge the desktop library (skip job TTL sweep) (#229)
The desktop app persists its track list permanently in ~/Documents/StemDeck/user-data.json, but stems under jobs/<id>/ were subject to the 24h job TTL sweep that runs at every startup. After a day (or any app restart past the TTL -- e.g. installing a new release) the sweep deleted the stems while the library entries remained, surfacing "This track's audio is no longer available. Re-upload to restore it." The TTL is a disk-hygiene default for the shared server/Docker deployment. On desktop the library is user-curated (folders + Trash), so skip the sweep when running under the desktop shell (STEMDECK_DESKTOP=1, set by the Tauri launcher on Windows/macOS/Linux). Disk stays under user control. Co-authored-by: Thales <> |
||
|
|
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 <>
|
||
|
|
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 <> |
||
|
|
0a67593ad4 | feat: add FLAC support (import and export) (#flac) (#194) | ||
|
|
7b566e1c2e |
feat: Export Mix reflects the mixer (volume, mute, solo) (#183) (#191)
Export Mix now renders on demand from the current mixer state - per-stem volume, mute, and solo - via a new /jobs/{id}/mixdown.{ext} ffmpeg endpoint. Master fader is intentionally excluded; Export All Stems stays raw. Adds 13 backend tests; verified end-to-end (gain 0.5 -> half RMS, amix sums faithfully).
|