Move the exact loop start/end fields inline into .footer-transport, directly
after the loop button, instead of a separate row below the time readout.
Drop the redundant LOOP label now that the fields sit next to the loop control.
Add two editable timestamp fields in the transport footer for setting the
loop region precisely, alongside the existing drag/click select. Fields
display mm:ss.mmm and accept either mm:ss.mmm or plain decimal seconds.
- utils.js: fmtTimeMs (integer-ms math, no rounding carry) and parseTimecode
(mm:ss.mmm or plain seconds, null on invalid).
- transport.js: syncLoopInputs keeps the fields in sync on drag/toggle
(never clobbering a field being edited, disabled when no track loaded);
commitLoopInput parses, clamps to [0, totalDuration], enforces the
MIN_LOOP_SEC ordering, then updates the loop via the existing setters +
updateLoopRegionVisual. Enter/blur commit, Escape reverts. Invalid input
reverts the field in place (showError belongs to the import form).
- player.js: refresh loop UI on track load so the inputs enable + reset once
the duration is known.
Values flow through the existing loopStart/loopEnd setters and
audioEngine.setLoop, so the model and engine are unchanged.
Switch the Windows FFmpeg download from gyan.dev (single US mirror, ~0.1 MB/s
for the reporter) to BtbN's GitHub build, served via GitHub's CDN. Uses the
pinned asset ffmpeg-n8.1-latest-win64-gpl-8.1.zip and verifies it against
BtbN's combined checksums.sha256 (one file listing every asset), replacing the
old per-file {url}.sha256 companion fetch. Fails closed if the archive is not
listed. Custom STEMDECK_FFMPEG_URL overrides still skip verification.
Also detect a manually-placed FFmpeg under data/ffmpeg/bin/ (the upstream
folder layout) in addition to the flat data/ffmpeg/ location, so users who drop
in their own build are honored instead of triggering a download. Detection now
flows through resolve_existing_ffmpeg across probe_runtime, ffmpeg_dir_if_present,
and ensure_ffmpeg; write_setup_config records ffprobe next to the resolved
ffmpeg.
Three changes to chunkedAudioEngine.js:
- CHUNK_SEC 10 -> 5: halves the initial chunk download (~10 MB -> ~5 MB for 6
stems), reducing the first-play buffering time.
- ready() no longer blocks on chunk 0 download. It resolves after the parallel
WAV header fetches (~6 x 1 KB) and kicks chunk 0/1 off in the background.
play() already handled the not-yet-cached case, so first play is gapless
once the background fetch finishes. Track appears ready in the UI in ~100 ms
instead of several seconds.
- Add SoundTouch WSOLA AudioWorklet on the master bus (same pattern as
audioEngine.js) and expose setPlaybackRate(). The mobile speed slider was
already wired to call this method, but it was missing from the API so the
control silently did nothing. Pitch is now preserved at all speeds on mobile.
* feat(player): playback speed control (0.5x to 2.0x)
Adds a speed slider to the transport footer (desktop) and mixer tab
(mobile) so users can slow down or speed up tracks for practice.
- audioEngine: store _playbackRate, apply to new AudioBufferSourceNodes
on startSources(), and fix getCurrentTime() to account for rate so
the waveform playhead and loop detection stay accurate at non-1x speeds
- transport: applySpeed() propagates rate to both engine and streaming
paths; scroll-wheel support (+-0.25 per tick); double-click resets to 1x
- state: playbackSpeed variable + setter; speedEl/speedLabelEl DOM refs
- player: resetSpeed() called in destroyPlayer() so a new track always
starts at 1x
- mobile: speed slider in mixer transport section, state.speed reset on
track open
* fix(player): move speed control below play button, centered
* fix(player): tempo bar full-width below transport, TEMPO label + gold slider
* fix(player): center 1.0x on tempo slider (range 0-2, midpoint = 1.0)
* feat(audio): pitch-preserving tempo via SoundTouch AudioWorklet
Voices and instruments no longer pitch-shift when changing playback speed.
A WSOLA time-stretcher runs as an AudioWorkletProcessor on the master bus
so a single node handles all stems. Falls back to tape-effect if the worklet
API is unavailable.
* fix(audio): close array literal in Promise.all ([]) was missing ]
Three changes to address NVIDIA RTX 5000 series (sm_120, cu128) running
stem separation on CPU instead of GPU:
- Bump cu128 torch from 2.7.1 to 2.8.0: 2.7.1 shipped incomplete sm_120
kernels for Blackwell, causing verify_cuda_torch to fail. 2.8.0+cu128
wheels are available and include full sm_120 support.
- Log verify_cuda_torch stderr to logs/setup.log: previously silenced
with Stdio::null(), so there was no diagnostic path when Blackwell
kernel verification failed.
- Show a visible error in setup.js when gpu_detected but cuda_verified
is false: the previous status-line message was easy to miss. Now calls
showError() so the user knows their GPU was found but CUDA setup failed
and where to look for details.
* feat(settings): QR codes for network access addresses
When server mode is on, show a scannable QR code for each local IP in
the desktop settings panel. Each QR encodes http://{ip}:{port}/mobile/
so the phone camera opens the mobile UI directly.
- Add segno (pure Python, no PIL) as a new dependency
- Add GET /api/qr?url=... endpoint that returns an SVG QR code
- Render one QR card per LAN address in the network settings section
* feat(settings): remove IP list, blur QR codes with tap-to-reveal
- Drop the yellow IP address chips; the QR label already shows the URL
- QR codes start blurred so a nearby camera app can't scan them
immediately; tap any card to toggle the blur
- Add a hint line: "Blurred so your camera doesn't get too excited. Tap to reveal."
* fix(settings): increase gap between QR cards
* fix(settings): clip QR blur bleed with overflow hidden wrapper
* fix(settings): accent color border on QR cards
* fix(settings): thicker accent border on QR cards
* fix(settings): box-sizing border-box on QR wrap to stop corner clipping
* fix(settings): advanced pane scrolls so Done footer stays fixed at bottom
Replaces full-file MP3 decode with a progressive WAV engine that fetches
stems in 10-second chunks and chains AudioBufferSourceNodes back-to-back.
- First audio after ~7 MB download (one chunk for 4 stems) vs. waiting
for the complete file
- Peak RAM ~28 MB vs. ~420 MB for a 5-minute 4-stem track
- No track-length cap (removes the 7-14 min OOM limit)
- Same glitch-free behavior on Safari/WKWebView: AudioBufferSourceNode,
no streaming elements, no HTTP/1.1 connection-cap underruns
- Backend needs no changes: Starlette FileResponse handles Range requests
Closes#236
* fix(mobile): guard against OOM crash on Load, fix stuck Preparing audio
Two bugs reported in #234 (via discussion #216):
1. Mobile browsers crash (WebKit tab kill) when loading long tracks because
createAudioEngine decodes all stems into AudioBuffers in parallel. Added
an estimateDecodedBytes check before creating the engine: tracks whose
decoded PCM would exceed 200 MB (approx 4.5 min x 4 stems) now surface a
clear error instead of silently crashing the tab.
2. After a crash-induced reload, loadLibrary auto-selected state.tracks[0]
without calling openTrack(), leaving the player stuck on "Preparing audio"
indefinitely. Removed the auto-select: the library is visible on load and
the user can pick a track explicitly.
Closes#234
* fix(mobile): replace OOM hard error with streaming engine fallback
Instead of blocking long tracks with an error, tracks that would exceed
200 MB of decoded PCM (approx 4.5 min x 4 stems) now fall back to a
streaming engine backed by <audio> elements and createMediaElementSource.
No PCM is held in RAM -- the browser streams on demand -- so OOM crashes
are avoided without restricting track length.
The streaming engine implements the same interface as createAudioEngine
(play/pause/seek/setGain/destroy/ready) so openTrack needs no structural
changes beyond selecting which engine to create.
* fix(mobile): play button state color, fix streaming engine seek desync
Play button: add data-playing attribute and CSS so the button turns green
when playing (gold when paused), making the state immediately obvious.
Seek desync: the streaming engine was setting currentTime on all <audio>
elements while they continued playing, causing each stem to arrive at the
new position at a slightly different time. Fix: pause all elements first,
seek all, then resume -- this guarantees all stems restart from the same
position simultaneously.
* feat(mobile): wire prev/next track buttons, match desktop icon button style
Prev/next buttons were rendered but had no data-action and no handler --
clicking them did nothing. Now:
- prevTrack() / nextTrack() navigate state.tracks by index, autoplaying if
a track was already playing when the button was pressed
- Buttons are disabled at the ends of the library (no prev on first track,
no next on last track)
- .t-step CSS updated to match the desktop daw-iconbtn style: transparent
background, rounded corners, hover highlight, scale(0.96) on active
* fix(mobile): proper seek sync and drift correction in streaming engine
Two-part fix for stems going out of sync after dragging the playhead:
1. Seek: pause all elements, set all currentTime, then wait one RAF frame
for the browser to settle the seeks. Re-read the primary element's
actual position and align all secondaries to it before calling play().
This eliminates startup desync caused by elements buffering at different
rates after a seek.
2. Drift correction: tick() now checks secondary elements every ~1 second
(60 RAF frames) and snaps any that have drifted more than 50ms back to
the primary's position. Catches any clock skew that accumulates during
long playback.
* fix(mobile): redesign prev/next buttons to match standard mobile player style
Remove the rectangular box shape (border-radius:9px) that clashed with the
circular play button. Prev/next are now naked icon buttons with a circular
tap area -- the pattern used by Spotify, Apple Music, etc. Icons enlarged
from 26px to 32px to sit proportionally next to the 66px play button.
Press feedback is a scale+opacity pop instead of a subtle background fill.
* chore(mobile): remove unimplemented speed and loop stub buttons
* fix(mobile): drop streaming engine, use buffer engine for all tracks
The streaming engine (<audio> elements via createMediaElementSource) caused
constant choppy audio -- the exact same HTTP/1.1 connection-cap underrun
issue that audioEngine.js was built to solve in the first place.
Replace with the buffer engine for all tracks. Raise the decoded-PCM limit
from 200 MB to 600 MB, which covers ~14 min x 4 stems at 44.1 kHz/Float32
-- well within the per-tab budget of any post-2019 phone. Tracks over that
threshold get a clear error rather than glitchy playback.
* fix(mobile): correct OOM limit comment and dynamic error message
600 MB / (stems * 2ch * 44100Hz * 4 bytes) = ~7 min for 4 stems, not 14.
The 14-minute figure was only accurate for a 2-stem track. Error message
now computes and shows the real cap based on the actual stem count.
* fix(dev): bind to 0.0.0.0 by default to allow LAN access for mobile testing
* fix(dev): align run.sh default port to 8080 to match settings and desktop
* 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.
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 <>
* 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 <>
* feat: add Empress Effects to We Recommend
Effects-pedal maker; links to empresseffects.com (website, so logo style /
no Instagram glyph, like Lisbon Guitar Works). Image can be dropped at
static/img/friends/empress-effects.png later; shows the monogram until then.
* fix: correct Thomann description to "Online Music Store"
---------
Co-authored-by: Thales <>
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 <>
The self-hosted runner advertises self-hosted/macOS/ARM64 (GitHub's
default macOS labels), but the workflow required a custom 'osx' label
that the runner no longer carries, so every macOS Release since
alpha.15 sat queued with no matching runner. Match the default labels,
consistent with the windows/linux release workflows.
Co-authored-by: Thales <>
* 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 <>
* 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 <>
Both Linux tarballs were 2.5 GB (over GitHub's 2 GiB asset limit) even
with CPU torch. Root cause: 'uv pip install <project>' pulls the default
Linux torch, which is the CUDA build, dragging in nvidia-* runtime
packages (cuDNN/cuBLAS/NCCL/...) and triton (~2.5 GB). The CPU torch swap
uses --force-reinstall --no-deps, so torch becomes CPU but those CUDA
packages stay installed and orphaned, bloating the tarball.
Uninstall the nvidia-* packages and triton after the swap. CPU torch does
not use them and the NVIDIA variant re-downloads CUDA at first run.
Co-authored-by: Thales <>
Root cause of the Linux apt hang: the wsl2 self-hosted runner does not
have passwordless sudo, so 'sudo apt-get' blocks forever at the password
prompt. The previous 'sudo timeout ... apt-get' guard did nothing because
sudo prompts BEFORE the inner timeout can start.
Since the build deps are already installed on the persistent runner, check
each package with dpkg (no sudo) and skip apt entirely when all are present.
Only touch apt if something is genuinely missing, using 'sudo -n' so it
fails fast with a clear message instead of hanging at a prompt.
Co-authored-by: Thales <>
The Linux release job hung for 30+ min on 'install build dependencies':
apt-get stalled on the persistent wsl2 runner (likely a dpkg lock held by
unattended-upgrades). The packages are already installed on that runner
from prior runs, so the install itself is a no-op -- the hang is in
apt-get update / lock acquisition.
Bound the apt commands with and DPkg::Lock::Timeout so a stuck
lock cannot hang the release, set DEBIAN_FRONTEND=noninteractive to avoid
debconf prompts, treat apt as best-effort, then verify webkit2gtk-4.1 is
actually present (fail loudly only if genuinely missing).
Co-authored-by: Thales <>
The Linux NVIDIA tarball baked the full CUDA torch wheel, producing an
asset >2 GiB that GitHub release uploads reject (size must be < 2147483648).
On Linux the default PyPI torch wheel bundles the CUDA runtime (~2.5 GB),
unlike Windows where the default wheel is CPU-only. The Windows NVIDIA
package therefore never baked CUDA -- it ships CPU torch and downloads the
CUDA wheel at first run via the desktop shell (install_cuda_torch, which is
cfg(not(macos)) and already covers Linux). Mirror that on Linux: bake the
small CPU torch in both variants; the NVIDIA variant differs only by
omitting the cpu-only marker, so the shell detects the GPU and downloads
CUDA on first launch. Keeps both tarballs well under the 2 GiB limit.
Co-authored-by: Thales <>
* fix(ci): source release tag from github.ref_name on Windows
The Windows release job failed at 'write version files' with
'GITHUB_REF_NAME is not set' on the org-level self-hosted win runner,
while the Linux runner saw the variable fine. $env:GITHUB_REF_NAME is
only injected by Actions runner >= 2.290, so an older self-hosted runner
leaves it empty.
Source the tag from the github.ref_name context (evaluated by Actions
before the step runs) via a job-level REF_NAME env var instead, so the
build no longer depends on the runner version. Replaces all three
$env:GITHUB_REF_NAME usages (version step + both build steps).
* fix(ci): source release tag from github.ref_name on Linux and macOS too
The Linux release job failed at 'write version files' with the same
empty-GITHUB_REF_NAME cause as Windows: the wsl2 self-hosted runner is
also older than 2.290. macOS uses the same pattern and the same class of
runner, so fix all three release workflows consistently to read
github.ref_name from context via a REF_NAME env var.
---------
Co-authored-by: Thales <>
* feat: add CPU-only Linux portable build and release workflow
Adds a Linux .tar.gz portable package mirroring the existing Windows/macOS
build paths. Bundles a python-build-standalone runtime (CPU torch + demucs)
plus the Tauri binary so users extract and run ./StemDeck.
- scripts/linux/make-portable.sh: stages PBS Python, force-installs CPU-only
torch, builds the Tauri binary, and produces StemDeck-Linux-x64.tar.gz with
the backend/app + python/ layout find_repo_root resolves at runtime.
- .github/workflows/linux-release.yml: builds on hosted ubuntu-latest on
release publish; installs Tauri v2 apt deps + uv, ClamAV-scans, uploads.
- packaging/linux/{README-LINUX,THIRD_PARTY_NOTICES}.txt: extract-and-run
instructions noting ffmpeg + WebKitGTK are system (apt) prerequisites.
FFmpeg is not bundled: the Linux shell expects ffmpeg on PATH. NVIDIA/CUDA
and AppImage variants are intentionally deferred to later phases.
* fix: don't set PYTHONHOME on Linux (breaks PBS stdlib resolution)
The Linux backend failed to start with 'ModuleNotFoundError: No module
named encodings'. PYTHONHOME was being set to python/bin instead of the
prefix python/, so CPython looked for its stdlib under python/bin/lib and
could not boot.
Linux bundles python-build-standalone exactly like macOS, which detects
its own prefix by walking up from bin/ and must NOT have PYTHONHOME set.
The two PYTHONHOME sites were gated #[cfg(not(target_os = "macos"))],
wrongly including Linux alongside Windows. Only Windows -- whose portable
venv keeps the stdlib under base/Lib -- needs PYTHONHOME, so gate both
sites (start_backend and python_stdlib_ok) to #[cfg(windows)].
This also fixes the latent inconsistency where probe_runtime reported
Python ready (python_stdlib_ok set PYTHONHOME=python, the correct prefix)
while start_backend set PYTHONHOME=python/bin and failed.
* feat: add NVIDIA/CUDA Linux portable variant
Adds a second Linux package, StemDeck-Linux-x64.NVIDIA.tar.gz, with
CUDA-enabled torch baked in (mirrors the Windows NVIDIA variant).
- make-portable.sh: CPU_ONLY toggle (default 1). CPU_ONLY=0 keeps the
project's default torch wheel, which on Linux x86_64 is the CUDA build,
and omits the cpu-only marker so the desktop shell detects the GPU and
uses CUDA at runtime. No app-side changes needed -- the CUDA detection/
install path in main.rs is already cfg(not(macos)) and covers Linux.
- linux-release.yml: builds both variants in one job. CPU first (full Tauri
build), then NVIDIA with SKIP_TAURI_BUILD=1 reusing the same binary. Adds
a free-disk-space step (CUDA bundle is several GB) and drops each
uncompressed stage after taring to stay within the hosted runner's disk.
- README-LINUX.txt: documents both variants and the NVIDIA driver
prerequisite (nvidia-smi must work; CUDA runtime is bundled, no toolkit
install needed; falls back to CPU when no GPU).
* ci: run Linux release on self-hosted linux/x64 runner
Targets the org's self-hosted wsl2 runner ([self-hosted, linux, x64])
instead of hosted ubuntu-latest, matching the Windows/macOS release
jobs. Drops the free-disk-space step: it was a hosted-runner workaround
and would needlessly rm system directories on a persistent self-hosted
box (WSL2's virtual disk has ample room for the CUDA bundle).
* ci: add workflow_dispatch test build for Linux release
Lets you run the full two-variant build + ClamAV scan on the self-hosted
runner without publishing a release, to validate the runner toolchain and
the CUDA build. Resolves the version from a manual input (default 0.0.0,
must be valid PEP 440) instead of the branch ref, and skips the upload
step on non-release events.
---------
Co-authored-by: Thales <>
* fix: support RTX 50-series (Blackwell sm_120) CUDA via cu128 wheels
The stock torch 2.6 cu12x wheels have no sm_120 kernels, so Blackwell
GPUs (e.g. RTX 5060 Ti) pass torch.cuda.is_available() but crash mid-
extraction with "no kernel image is available for execution on the
device" (#217).
Targeted carve-out so existing users are untouched:
- detect_nvidia_gpu now also reads the GPU compute capability
- wheel_tag routes sm_100/sm_120 (cap major >= 10) to cu128, everything
else keeps the existing cu124/cu121/cu118 heuristic
- install_cuda_torch installs torch 2.7.1+cu128 for the cu128 tag and
stays on 2.6.0 for all other tags
- verify_cuda_torch now forces a real kernel launch instead of trusting
is_available(), so an incompatible wheel falls back to CPU cleanly
instead of crashing during a job
Closes#217
* fix(deps): bump msgpack 1.1.2 -> 1.2.1 (GHSA-6v7p-g79w-8964)
Trivy flags msgpack 1.1.2 with a HIGH advisory (out-of-bounds read /
crash on Unpacker reuse). It's a transitive dep via the torch/demucs
stack; bump to the fixed 1.2.1 to clear the trivy fs scan.
---------
Co-authored-by: Thales <>
* ci: add GitHub Actions CI workflow (replaces Woodpecker ci.yml)
Parallel jobs: lint, test, js-syntax, sast-bandit, deps-audit, trivy.
Trivy now uses aquasecurity/trivy-action instead of the container image.
* fix: upgrade yt-dlp, starlette, python-multipart; ignore new torch CVEs
- yt-dlp 2026.3.17 -> 2026.6.9 (fixes CVE-2026-50023, CVE-2026-50574, GHSA-69qj-pvh9-c5wg)
- starlette 1.0.0 -> 1.3.1 (fixes CVE-2026-48818, CVE-2026-54283)
- python-multipart 0.0.27 -> 0.0.32 (fixes CVE-2026-53539)
- Add CVE-2025-2148/2149/2998/2999/3000/3001 to deps-audit ignore list:
torch is pinned below 2.7 due to torchaudio/demucs compat; these CVEs
are in ops StemDeck does not invoke.
* ci: add macOS and Windows release workflows + Dependabot config
- macos-release.yml: builds arm64 and x64 DMGs on self-hosted macOS runner,
inspects artifacts, uploads to GitHub release via softprops/action-gh-release
- windows-release.yml: builds NVIDIA and CPU portable ZIPs on self-hosted
Windows runner, scans with ClamAV, uploads to GitHub release
- dependabot.yml: weekly action SHA bumps for all workflows
- Both release workflows: permissions locked to read-only at workflow level,
contents:write only on the job; concurrency guard; 120/90 min timeouts;
workspace cleanup; all actions pinned to SHA
* ci: use osx runner label for macOS release workflow
Adds Joao Gaspar and Kris Luthier (with bundled Instagram profile images) to the Supporters dialog; points Dlima Guitars at Instagram. Tiles gain optional role lines, round avatars for IG photos, a small Instagram glyph on IG-linked tiles, and a masonry/tilted layout. Warmer dialog tagline. README gains a We Recommend section with a no-funding disclaimer, and the old donation line now points to it.
Adds Joao Gaspar and Kris Luthier (with locally bundled IG profile images) to the Supporters dialog. Tiles gain an optional role line, render gracefully without a logo, and lay out as independent masonry columns with a slight per-tile tilt (frames-on-a-wall look) that straightens on hover.
CI never sets RELEASE_BASE_URL, so every macOS release manifest baked the old thcp/stemdeck download URL. It only worked via GitHub's transfer redirect, which is outside our control and would 404 if a repo named stemdeck is ever recreated under thcp. Point the default at the repo we own. Future releases only.
Adds the community health files: Contributor Covenant 2.1 Code of Conduct (private reporting via Security tab), a Contributing guide (layout, dev setup, CI gates, commit/PR conventions), and .github/ISSUE_TEMPLATE YAML forms (bug, feature) with config.yml linking Discussions/Discord/security.
A TV icon in the sidebar rail (between Settings and Help) opens an About-style 'Supporters' dialog with partner tiles (Dlima Guitars, Lisbon Guitar Works), clickable to their sites. Logos bundled; rail widened 56->66px so the label fits. Verified headless.
Subset extractions misaligned the waveform overlay and (after the first attempt) showed non-extracted stems. Render one row per mixer lane for alignment, and draw bars only for the extracted/selected stems (plus original); other lanes stay empty. Verified headless.
On a DMG upgrade the old runtime was kept because setup.js checked the runtime version after the 'runtime ready' early-return (dead since #779a2e6). Move the check before the early-return and treat unknown installed versions as a mismatch, so upgrades re-download the new runtime (backend+frontend). Also compare update-banner versions canonically (PEP440 vs tag) so a current app is not nagged.
macOS setup ran the downloaded FFmpeg/ffprobe with no integrity check. evermeet publishes no .sha256 companion, so pin a specific versioned build + its SHA256 and verify before extract/exec; mismatch deletes the file. Override URLs may supply STEMDECK_FFMPEG_SHA256/STEMDECK_FFPROBE_SHA256. Adds 4 Rust tests.
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).
Two regressions from #187 (SVG overview became the visible waveform in engine mode): a constant 48px left gap vs the ruler/playhead, and the bar art style replaced by a filled envelope. Zero the legacy 48px offset in the DAW layout and render the overview as WaveSurfer-style bars (3px bar / 2px gap, baseline for silence). Verified headless in WebKit.