Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ htmlcov/
data/
jobs/
.run/
.build

# Local env / secrets
.env
Expand Down
9 changes: 9 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# CVE-2025-32434: torch remote code execution via torch.load, fixed in 2.6.0.
# The Intel macOS (x86_64) runtime is pinned to torch>=2.2,<2.3 because
# PyTorch does not publish macOS x86_64 wheels for 2.6.x. StemDeck never
# calls torch.load() on untrusted input; all model weights are fetched by
# Demucs from its own trusted cache. Risk on a local single-user app with
# no network-facing torch.load path is negligible.
# Drop this ignore once PyTorch publishes 2.6.x macOS x86_64 wheels or once
# the torchaudio/torchcodec story stabilises and the pin can be lifted.
CVE-2025-32434
1 change: 1 addition & 0 deletions .woodpecker/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ steps:
--severity HIGH,CRITICAL
--exit-code 1
--ignore-unfixed
--ignorefile .trivyignore
--skip-dirs .venv
--skip-dirs jobs
.
Expand Down
149 changes: 149 additions & 0 deletions .woodpecker/macos-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
when:
- event: release
- event: tag

labels:
platform: darwin/arm64
backend: local

steps:
prepare-macos-version:
image: bash
commands:
- |
if [ -z "${CI_COMMIT_TAG:-}" ]; then
echo "CI_COMMIT_TAG is not set" >&2
exit 1
fi
VERSION="${CI_COMMIT_TAG#v}"
printf '{ "version": "%s" }\n' "$VERSION" > static/version.json
echo "VERSION=$VERSION" > .macos-release.env
echo "Wrote static/version.json for $VERSION"

build-macos-arm64:
image: bash
depends_on:
- prepare-macos-version
commands:
- . ./.macos-release.env
- command -v zstd
- scripts/macos/make-iconset.sh
- ARCH=arm64 VERSION="$VERSION" scripts/macos/make-runtime-pack.sh
- ARCH=arm64 VERSION="$VERSION" scripts/macos/make-app.sh
- ARCH=arm64 VERSION="$VERSION" scripts/macos/make-dmg.sh

build-macos-x64:
image: bash
depends_on:
- prepare-macos-version
commands:
- . ./.macos-release.env
- command -v zstd
- |
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "ERROR: Rosetta 2 is required to build the x64 runtime on this agent." >&2
exit 1
fi
- |
if ! command -v rustup >/dev/null 2>&1 || ! rustup target list --installed | grep -q x86_64-apple-darwin; then
echo "ERROR: x86_64-apple-darwin Rust target not installed. Run: rustup target add x86_64-apple-darwin" >&2
exit 1
fi
- ARCH=x64 VERSION="$VERSION" scripts/macos/make-runtime-pack.sh
- ARCH=x64 VERSION="$VERSION" scripts/macos/make-app.sh
- ARCH=x64 VERSION="$VERSION" scripts/macos/make-dmg.sh

inspect-macos-artifacts:
image: bash
depends_on:
- build-macos-arm64
- build-macos-x64
commands:
- test -f .build/macos-dist/StemDeck-macOS-arm64.dmg
- test -f .build/StemDeck-runtime-macOS-arm64.tar.zst
- test -f .build/macos-dist/SHA256SUMS-macOS-arm64.txt
- test -f .build/macos-dist/StemDeck-macOS-x64.dmg
- test -f .build/StemDeck-runtime-macOS-x64.tar.zst
- test -f .build/macos-dist/SHA256SUMS-macOS-x64.txt
- cat .build/macos-dist/SHA256SUMS-macOS-arm64.txt
- cat .build/macos-dist/SHA256SUMS-macOS-x64.txt
- du -sh .build/macos-dist/StemDeck-macOS-arm64.dmg .build/StemDeck-runtime-macOS-arm64.tar.zst
- du -sh .build/macos-dist/StemDeck-macOS-x64.dmg .build/StemDeck-runtime-macOS-x64.tar.zst
- |
if find desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app \
\( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) |
grep -q .; then
echo "arm64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2
exit 1
fi
- |
if find desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app \
\( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) |
grep -q .; then
echo "x64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2
exit 1
fi
- |
for arch in arm64 x64; do
mountpoint="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)"
hdiutil attach ".build/macos-dist/StemDeck-macOS-${arch}.dmg" -readonly -nobrowse -mountpoint "$mountpoint"
trap 'hdiutil detach "$mountpoint" >/dev/null 2>&1 || true; rmdir "$mountpoint" >/dev/null 2>&1 || true' EXIT
test -d "$mountpoint/StemDeck.app"
test -L "$mountpoint/Applications"
test -f "$mountpoint/README-macOS.txt"
test -f "$mountpoint/THIRD_PARTY_NOTICES.txt"
hdiutil detach "$mountpoint"
rmdir "$mountpoint"
trap - EXIT
done

upload-macos-artifacts:
image: bash
when:
- event: release
depends_on:
- inspect-macos-artifacts
environment:
GH_TOKEN:
from_secret: github_token
commands:
- |
if [ -z "${CI_COMMIT_TAG:-}" ]; then
echo "CI_COMMIT_TAG is required to upload release assets." >&2
exit 1
fi
- |
if ! command -v gh >/dev/null 2>&1; then
echo "GitHub CLI is required on the macOS agent to upload release assets." >&2
exit 1
fi
- |
scan_note="macOS arm64 and x64 DMGs and runtime packs were built and inspected on a macOS Woodpecker agent before upload."
body="$(gh release view "$CI_COMMIT_TAG" --repo "thcp/stemdeck" --json body --jq ".body")"
case "$body" in
*"$scan_note"*) ;;
*)
notes_path="$(mktemp /tmp/stemdeck-release-notes.XXXXXX.md)"
{
printf '%s\n\n' "$body"
printf '### Artifact build\n\n'
printf -- '- %s\n' "$scan_note"
} > "$notes_path"
gh release edit "$CI_COMMIT_TAG" --repo "thcp/stemdeck" --notes-file "$notes_path"
rm -f "$notes_path"
;;
esac
- |
existing=$(gh release view "$CI_COMMIT_TAG" --repo "thcp/stemdeck" --json assets --jq '[.assets[].name] | join(" ")')
if echo "$existing" | grep -q "StemDeck-macOS-arm64.dmg"; then
echo "macOS assets already present for $CI_COMMIT_TAG — skipping upload (pre-release promotion)."
exit 0
fi
gh release upload "$CI_COMMIT_TAG"
".build/macos-dist/StemDeck-macOS-arm64.dmg"
".build/StemDeck-runtime-macOS-arm64.tar.zst"
".build/macos-dist/SHA256SUMS-macOS-arm64.txt"
".build/macos-dist/StemDeck-macOS-x64.dmg"
".build/StemDeck-runtime-macOS-x64.tar.zst"
".build/macos-dist/SHA256SUMS-macOS-x64.txt"
--repo "thcp/stemdeck"
10 changes: 8 additions & 2 deletions .woodpecker/windows-release.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
when:
- event: release
- event: tag

labels:
platform: windows/amd64
Expand Down Expand Up @@ -98,10 +99,15 @@ steps:
Set-Content -Path $notesPath -Value $updatedBody -Encoding UTF8
gh release edit "$env:CI_COMMIT_TAG" --repo "thcp/stemdeck" --notes-file $notesPath
}
- gh release upload "$env:CI_COMMIT_TAG"
- |
$existing = gh release view "$env:CI_COMMIT_TAG" --repo "thcp/stemdeck" --json assets --jq '[.assets[].name] | join(" ")'
if ($existing -like "*StemDeck-Windows-x64.zip*") {
Write-Host "Windows assets already present for $env:CI_COMMIT_TAG — skipping upload (pre-release promotion)."
exit 0
}
gh release upload "$env:CI_COMMIT_TAG"
"dist/StemDeck-Windows-x64.NVIDIA.zip"
"dist/StemDeck-Windows-x64.NVIDIA.zip.sha256"
"dist/StemDeck-Windows-x64.zip"
"dist/StemDeck-Windows-x64.zip.sha256"
--repo "thcp/stemdeck"
--clobber
65 changes: 54 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<div align="center">

![StemDeck](stemdeck.png)

# StemDeck
<img src="imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg" alt="StemDeck" width="515" />

**Free, local stem separation. No account. No upload. No subscription.**

Expand Down Expand Up @@ -84,7 +82,20 @@ If you need speed, quality, mobile access, or the extra musician tooling, the co

## Download

Pre-built portable zips are attached to each [GitHub Release](https://github.com/thcp/stemdeck/releases). No installer needed; extract and run.
Pre-built installers and zips are attached to each [GitHub Release](https://github.com/thcp/stemdeck/releases).

**macOS**

| DMG | GPU | Chip |
|---|---|---|
| `StemDeck-macOS-arm64.dmg` | Apple Silicon (MPS) | M1 and later |
| `StemDeck-macOS-x64.dmg` | CPU only | Intel |

Open the DMG, drag StemDeck to Applications, and launch it. On first launch the setup screen downloads the Python runtime (~500 MB), FFmpeg, and the Demucs model (~170 MB). Subsequent launches skip setup and start in seconds. No Python or system dependencies required.

macOS may show a Gatekeeper prompt on first open — right-click the app and choose Open to bypass it.

**Windows**

| Zip | GPU | Approx. size |
|---|---|---|
Expand All @@ -97,21 +108,53 @@ Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies t

## Technologies

StemDeck is built on **[Python 3.10+](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The Windows desktop shell is **[Tauri v2](https://tauri.app)** (Rust/WebView2). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `<canvas>` using min/max sample rendering.
StemDeck is built on **[Python 3.10+](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `<canvas>` using min/max sample rendering.

*Thanks to the creators and maintainers of all the open-source libraries that make StemDeck possible.*

---

## Build from Source

*(macOS / Linux / Windows with Python 3.10+)*
### macOS Native App

Requires Rust, Node.js, and Python 3.10–3.13. Builds a self-contained `.app` that downloads its own runtime on first launch.

```sh
# First time only — add the cross-compilation targets
rustup target add aarch64-apple-darwin # Apple Silicon
rustup target add x86_64-apple-darwin # Intel

# Build Apple Silicon
ARCH=arm64 scripts/macos/make-runtime-pack.sh
ARCH=arm64 scripts/macos/make-app.sh
ARCH=arm64 scripts/macos/make-dmg.sh

# Build Intel (requires Rosetta 2 and an x86_64 Python)
ARCH=x64 scripts/macos/make-runtime-pack.sh
ARCH=x64 scripts/macos/make-app.sh
ARCH=x64 scripts/macos/make-dmg.sh
```

The `.app` lands at `desktop/src-tauri/target/<target>/release/bundle/macos/StemDeck.app`. The DMG lands at `.build/macos-dist/StemDeck-macOS-<arch>.dmg`.

To run a fresh build directly without the DMG:

```sh
# Wipe previous app data, then open
rm -rf ~/Library/Application\ Support/StemDeck
open desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app
```

---

### Web Server (macOS / Linux / Windows with Python 3.10+)

### Prerequisites
#### Prerequisites

Python 3.10 or newer, `ffmpeg` on your PATH, and [uv](https://github.com/astral-sh/uv). Around 170 MB of free disk for the Demucs model, which downloads automatically on first run.

### macOS / Linux (one-shot)
#### macOS / Linux (one-shot)

```sh
git clone https://github.com/thcp/stemdeck stemdeck && cd stemdeck
Expand All @@ -123,23 +166,23 @@ Open <http://localhost:8000>.

`setup` uses Homebrew on macOS and `apt-get` on Debian/Ubuntu. For other Linux distros, install `ffmpeg` and [uv](https://github.com/astral-sh/uv) manually, then run `uv sync` followed by `./run.sh start`.

### Manual (any platform)
#### Manual (any platform)

```sh
git clone https://github.com/thcp/stemdeck stemdeck && cd stemdeck
uv sync
uv run uvicorn app.main:app --reload
```

### Docker
#### Docker

```sh
docker compose -f build/docker-compose.yml up --build
```

Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume so they don't re-download on rebuild. Note: no GPU passthrough on macOS Docker.

### `run.sh` control script
#### `run.sh` control script

```sh
./run.sh setup # one-shot: install ffmpeg + uv, then uv sync
Expand Down
24 changes: 24 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError
Expand Down Expand Up @@ -69,9 +70,32 @@ async def _sweep_loop() -> None:
await asyncio.sleep(3600)


async def _desktop_parent_watchdog(parent_pid: int) -> None:
while True:
try:
os.kill(parent_pid, 0)
except ProcessLookupError:
_log.info("desktop parent process exited; stopping backend")
os._exit(0)
except PermissionError:
_log.warning("desktop parent process check denied; stopping backend")
os._exit(0)
await asyncio.sleep(1)


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
asyncio.create_task(_sweep_loop())
if os.environ.get("STEMDECK_DESKTOP") == "1":
parent_pid = os.environ.get("STEMDECK_PARENT_PID")
if parent_pid:
try:
parent_pid_int = int(parent_pid)
except ValueError:
_log.warning("invalid STEMDECK_PARENT_PID=%r", parent_pid)
else:
if parent_pid_int > 0 and parent_pid_int != os.getpid():
asyncio.create_task(_desktop_parent_watchdog(parent_pid_int))
yield


Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stemdeck-desktop",
"version": "0.1.0-alpha.0",
"version": "0.4.0-alpha.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading