chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:09 +08:00
commit 8f10353f0c
135 changed files with 37786 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.venv/
jobs/
**/__pycache__/
*.pyc
.git/
.gitignore
.idea/
.vscode/
.DS_Store
.python-version
README.md
+72
View File
@@ -0,0 +1,72 @@
name: Bug report
description: Something isn't working as expected.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: Thanks for reporting a bug. Please fill in the details so we can reproduce it.
- type: checkboxes
id: preflight
attributes:
label: Before reporting
options:
- label: I'm on the latest release and the issue still happens.
required: true
- label: I searched existing issues and didn't find a duplicate.
required: true
- type: textarea
id: what
attributes:
label: What happened?
description: A clear description of the bug and what you expected instead.
placeholder: When I ..., StemDeck ... but I expected ...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open StemDeck
2. Import ...
3. Click ...
4. See error
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
- Other
validations:
required: true
- type: input
id: version
attributes:
label: StemDeck version
description: Shown in the About dialog (Help icon).
placeholder: e.g. v0.7.0-alpha.12
validations:
required: true
- type: dropdown
id: install
attributes:
label: How did you install it?
options:
- macOS DMG
- Windows ZIP
- From source
- Docker / self-hosted
validations:
required: true
- type: textarea
id: extra
attributes:
label: Logs / screenshots
description: Drag in screenshots or a recording, and paste any error text as code.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/stemdeckapp/stemdeck/discussions
about: Ask questions, share setups, or discuss ideas with the community.
- name: Discord
url: https://discord.gg/2MVsWqaPRe
about: Chat with the StemDeck community in real time.
- name: Report a security vulnerability
url: https://github.com/stemdeckapp/stemdeck/security/advisories/new
about: Please report security issues privately, not as a public issue.
@@ -0,0 +1,25 @@
name: Feature request
description: Suggest an idea or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: Thanks for the suggestion! Tell us what you'd like and why.
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: What are you trying to do that's hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+143
View File
@@ -0,0 +1,143 @@
# GitHub Actions: lint + unit tests + security scans.
# Does not build or publish artifacts. Image scanning is done via
# trivy fs on the project tree (covers deps, secrets, and Dockerfile
# misconfig) so CI does not need a docker-in-docker setup.
name: CI
on:
pull_request:
push:
branches: [main]
release:
types: [published]
env:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
# makes setuptools_scm raise, so pin a placeholder for the build -- CI only
# lints/tests and never publishes. #169
SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
jobs:
lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- uses: actions/checkout@v7
- run: uv sync --frozen --all-extras
- run: uv run ruff check app/ tests/
- run: uv run ruff format --check app/ tests/
- run: bash -n run.sh
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- uses: actions/checkout@v7
- run: apt-get update && apt-get install -y --no-install-recommends ffmpeg
- run: uv sync --frozen --all-extras
- run: uv run pytest tests/ -q
js-syntax:
runs-on: ubuntu-latest
container:
image: node:20-alpine
steps:
- uses: actions/checkout@v7
- run: for f in static/js/*.js; do node --check "$f"; done
sast-bandit:
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- uses: actions/checkout@v7
- run: uv tool install bandit
- run: uv tool run bandit -r app/ -ll # fail on medium+ severity
deps-audit:
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- uses: actions/checkout@v7
- run: uv tool install pip-audit
- run: uv pip compile pyproject.toml -o /tmp/requirements.txt
# Ignored CVEs (review when upgrading torch or demucs):
#
# torch 2.6.0 -- pinned to <2.7 because torchaudio 2.7+ removed its
# built-in audio writer and now requires torchcodec, which has ABI
# issues that break demucs 4.0.1's torchaudio.save() path. All torch
# CVEs below are in ops that StemDeck does not invoke; risk on a
# local-only, single-user app is negligible. Re-evaluate once demucs
# supports torch 2.7+ without torchcodec.
#
# joblib PYSEC-2024-277 -- no fix version available as of 2026-05-21
# (1.5.3 is latest). joblib is a transitive dep via demucs/librosa;
# StemDeck does not directly invoke joblib serialization. Drop once
# a patched release is available.
- run: |
uv tool run pip-audit -r /tmp/requirements.txt --strict \
--ignore-vuln CVE-2025-2953 \
--ignore-vuln CVE-2025-3730 \
--ignore-vuln PYSEC-2025-189 \
--ignore-vuln PYSEC-2025-190 \
--ignore-vuln PYSEC-2025-192 \
--ignore-vuln PYSEC-2025-193 \
--ignore-vuln PYSEC-2025-194 \
--ignore-vuln PYSEC-2025-195 \
--ignore-vuln PYSEC-2025-196 \
--ignore-vuln PYSEC-2025-197 \
--ignore-vuln PYSEC-2025-198 \
--ignore-vuln PYSEC-2025-199 \
--ignore-vuln PYSEC-2025-200 \
--ignore-vuln PYSEC-2025-201 \
--ignore-vuln PYSEC-2025-202 \
--ignore-vuln PYSEC-2025-203 \
--ignore-vuln PYSEC-2025-204 \
--ignore-vuln PYSEC-2025-205 \
--ignore-vuln PYSEC-2025-206 \
--ignore-vuln PYSEC-2025-207 \
--ignore-vuln PYSEC-2025-208 \
--ignore-vuln PYSEC-2025-209 \
--ignore-vuln PYSEC-2025-210 \
--ignore-vuln PYSEC-2026-139 \
--ignore-vuln PYSEC-2024-277 \
--ignore-vuln CVE-2025-2148 \
--ignore-vuln CVE-2025-2149 \
--ignore-vuln CVE-2025-2998 \
--ignore-vuln CVE-2025-2999 \
--ignore-vuln CVE-2025-3000 \
--ignore-vuln CVE-2025-3001
trivy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Scans the source tree for: known CVEs in deps, leaked secrets,
# and Dockerfile / compose misconfigurations. Skips .venv (it can
# be left over from earlier steps in the shared workspace; trivy
# would scan its bundled extractor files and flag false-positive
# secrets that ship inside third-party packages like yt-dlp).
- name: trivy fs
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
severity: HIGH,CRITICAL
exit-code: '1'
ignore-unfixed: true
trivyignores: .trivyignore
skip-dirs: .venv,jobs
# Dedicated Dockerfile + compose static analysis (Trivy's IaC linter).
- name: trivy config
uses: aquasecurity/trivy-action@master
with:
scan-type: config
scan-ref: build/
severity: HIGH,CRITICAL
exit-code: '1'
+94
View File
@@ -0,0 +1,94 @@
name: Docker Publish
# Builds the server container (build/Dockerfile) and pushes it to GHCR so it can
# be pulled by self-hosted deployments and the Unraid Community Applications app
# (ghcr.io/stemdeckapp/stemdeck). The image keeps the default Linux x86_64 torch
# wheel, which is the CUDA build -- so the same image runs on CPU by default and
# uses the GPU automatically when started with `--runtime=nvidia` (see the
# Unraid template in templates/unraid/).
on:
# Every merge to main publishes a rolling :edge image. The version is derived
# from git (hatch-vcs style); :edge never clobbers :latest.
push:
branches: [main]
release:
# prereleased: a prerelease is published -> push the version tag only.
# released: a stable release is published, OR a prerelease is promoted to
# a full/Latest release -> push the version tag AND :latest.
# (Listening to these two instead of `published` avoids a double run on a
# stable publish, which fires both `published` and `released`.)
types: [prereleased, released]
# Same as a main push but on demand, from any ref.
workflow_dispatch:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/stemdeck
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so git describe can derive a version on manual runs.
fetch-depth: 0
# Derive the version fed to the Dockerfile's VERSION build-arg (consumed as
# SETUPTOOLS_SCM_PRETEND_VERSION, so it MUST be PEP 440-valid). On a
# release, use the tag. Otherwise derive from git: `git describe --long`
# yields `<tag>-<N>-g<sha>`, which is NOT PEP 440 -- rewrite the trailing
# `-N-gSHA` into a `+N.gSHA` local segment (e.g. 0.8.0-alpha.5+3.gce86e8a).
- name: resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "release" ]; then
v="${{ github.event.release.tag_name }}"
else
raw="$(git describe --tags --long 2>/dev/null || echo "0.0.0-0-g$(git rev-parse --short HEAD)")"
v="$(printf '%s' "${raw#v}" | sed -E 's/-([0-9]+)-g([0-9a-f]+)$/+\1.g\2/')"
fi
echo "value=${v#v}" >> "$GITHUB_OUTPUT"
- name: docker metadata (tags/labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=edge,enable=${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'released' }}
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: build/Dockerfile
# Unraid is x86_64; arm64 has no CUDA torch and is not a target.
platforms: linux/amd64
push: true
build-args: |
VERSION=${{ steps.ver.outputs.value }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
+147
View File
@@ -0,0 +1,147 @@
name: Linux Release
on:
release:
types: [published]
# Manual test build: builds and scans both variants on the self-hosted runner
# but does NOT upload (no release to attach to). Lets you validate the runner
# toolchain and the CUDA build without cutting a release tag.
workflow_dispatch:
inputs:
version:
description: "Version for the test build (must be valid PEP 440, e.g. 0.0.0)"
required: false
default: "0.0.0"
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-upload:
# Runner must be linux/x64 with rustup, Node.js, Docker, and sudo apt access.
runs-on: [self-hosted, linux, x64]
timeout-minutes: 90
permissions:
contents: write
defaults:
run:
shell: bash
steps:
- name: clean workspace
run: rm -rf .build dist
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: install build dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
# Tauri v2 build deps + tooling. webkit2gtk-4.1 matches the tauri = "2"
# crate; ffmpeg is a runtime dependency, not bundled.
#
# The self-hosted runner does NOT have passwordless sudo, so a bare
# `sudo apt-get` hangs forever at the password prompt. These packages are
# already installed on the persistent runner, so check first (dpkg needs
# no sudo) and skip apt entirely when everything is present. Only if a
# package is genuinely missing do we touch apt, via `sudo -n` (fails fast
# instead of prompting) so a release never hangs on sudo again.
PKGS="build-essential curl file wget libssl-dev libxdo-dev \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev"
missing=""
for p in $PKGS; do
dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"
done
if [ -z "$missing" ]; then
echo "All build dependencies already installed; skipping apt."
exit 0
fi
echo "Missing packages:$missing"
if ! sudo -n true 2>/dev/null; then
echo "ERROR: build deps missing and passwordless sudo is not available." >&2
echo "Install on the runner: sudo apt-get install -y$missing" >&2
exit 1
fi
sudo -n apt-get update -o DPkg::Lock::Timeout=120
sudo -n apt-get install -y --no-install-recommends -o DPkg::Lock::Timeout=120 $missing
- name: install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: install rust toolchain
run: rustup default stable
- name: write version files
env:
DISPATCH_VERSION: ${{ inputs.version }}
# github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME,
# which is only injected by runner >= 2.290 — empty on older
# self-hosted runners.
REF_NAME: ${{ github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${DISPATCH_VERSION#v}"
else
VERSION="${REF_NAME#v}"
fi
if [ -z "$VERSION" ]; then
echo "Could not determine a version" >&2
exit 1
fi
printf '{ "version": "%s" }\n' "$VERSION" > static/version.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "Wrote version $VERSION to all version files"
- name: build Linux CPU
run: |
PACKAGE_NAME=StemDeck-Linux-x64 \
PACKAGE_VERSION="$VERSION" \
bash scripts/linux/make-portable.sh
# Drop the uncompressed stage but keep the tarball; frees disk for the
# larger NVIDIA build. The Tauri binary under target/ is preserved.
rm -rf dist/StemDeck-Linux-x64
- name: build Linux NVIDIA
run: |
# CPU_ONLY=0 keeps the CUDA torch wheel; SKIP_TAURI_BUILD=1 reuses the
# binary built in the CPU step (identical for both variants).
PACKAGE_NAME=StemDeck-Linux-x64.NVIDIA \
PACKAGE_VERSION="$VERSION" \
CPU_ONLY=0 \
SKIP_TAURI_BUILD=1 \
bash scripts/linux/make-portable.sh
rm -rf dist/StemDeck-Linux-x64.NVIDIA
- name: scan artifacts
run: |
echo "Artifacts staged in: $PWD/dist"
ls -lh dist
echo "SHA256 checksums:"
( cd dist && sha256sum *.tar.gz )
echo "Pulling latest ClamAV scanner image..."
docker pull clamav/clamav:latest
echo "Running ClamAV scan over dist/..."
docker run --rm -v "$PWD/dist:/scan:ro" clamav/clamav:latest \
clamscan --recursive --infected --bell /scan
echo "ClamAV scan completed successfully. No infected files reported."
- name: upload artifacts
# Only attach to a real release; a manual test build has nothing to upload to.
if: github.event_name == 'release'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
dist/StemDeck-Linux-x64.tar.gz
dist/StemDeck-Linux-x64.tar.gz.sha256
dist/StemDeck-Linux-x64.NVIDIA.tar.gz
dist/StemDeck-Linux-x64.NVIDIA.tar.gz.sha256
+128
View File
@@ -0,0 +1,128 @@
name: macOS Release
on:
release:
types: [published]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-upload:
# Runner must be darwin/arm64 with Rosetta 2, Xcode CLT, Homebrew, rustup, and zstd.
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 120
permissions:
contents: write
defaults:
run:
shell: bash
steps:
- name: clean workspace
run: rm -rf .build dist
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: write version files
env:
# github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME,
# which is only injected by runner >= 2.290 — empty on older
# self-hosted runners.
REF_NAME: ${{ github.ref_name }}
run: |
if [ -z "${REF_NAME:-}" ]; then
echo "REF_NAME is not set" >&2
exit 1
fi
VERSION="${REF_NAME#v}"
printf '{ "version": "%s" }\n' "$VERSION" > static/version.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "Wrote version $VERSION to all version files"
- name: build macOS arm64
env:
SSL_CERT_FILE: /etc/ssl/cert.pem
REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem
run: |
command -v zstd
command -v uv >/dev/null 2>&1 || brew install uv
uv python install cpython-3.12-macos-aarch64-none
ARM64_PYTHON="$(uv python find cpython-3.12-macos-aarch64-none)"
scripts/macos/make-iconset.sh
ARCH=arm64 VERSION="$VERSION" PYTHON_BIN="$ARM64_PYTHON" scripts/macos/make-runtime-pack.sh
ARCH=arm64 VERSION="$VERSION" scripts/macos/make-app.sh
ARCH=arm64 VERSION="$VERSION" scripts/macos/make-dmg.sh
- name: build macOS x64
env:
SSL_CERT_FILE: /etc/ssl/cert.pem
REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem
run: |
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
rustup default stable
rustup target add x86_64-apple-darwin
command -v uv >/dev/null 2>&1 || brew install uv
uv python install cpython-3.12-macos-x86_64-none
X64_PYTHON="$(uv python find cpython-3.12-macos-x86_64-none)"
ARCH=x64 VERSION="$VERSION" PYTHON_BIN="$X64_PYTHON" scripts/macos/make-runtime-pack.sh
ARCH=x64 VERSION="$VERSION" scripts/macos/make-app.sh
ARCH=x64 VERSION="$VERSION" scripts/macos/make-dmg.sh
- name: inspect artifacts
run: |
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
- name: upload artifacts
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
.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
+103
View File
@@ -0,0 +1,103 @@
name: Windows Release
on:
release:
types: [published]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-upload:
# Runner must be windows/x64 with PowerShell, Docker, and rustup.
runs-on: [self-hosted, windows, x64]
timeout-minutes: 90
permissions:
contents: write
# Source the tag from the github context (evaluated by Actions) rather than
# $env:GITHUB_REF_NAME, which is only injected by runner >= 2.290. Keeps the
# build working on older self-hosted runners. (#212 follow-up)
env:
REF_NAME: ${{ github.ref_name }}
defaults:
run:
shell: powershell
steps:
- name: clean workspace
run: |
Remove-Item -Recurse -Force .build, dist -ErrorAction SilentlyContinue
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: write version files
run: |
$tag = $env:REF_NAME
if (-not $tag) { throw "REF_NAME is not set" }
$version = $tag -replace '^v', ''
$json = "{`"version`": `"$version`"}"
Set-Content -Path "static/version.json" -Value $json -Encoding UTF8
(Get-Content "desktop/src-tauri/Cargo.toml") -replace '^version = ".*"', "version = `"$version`"" |
Set-Content "desktop/src-tauri/Cargo.toml"
(Get-Content "desktop/src-tauri/tauri.conf.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" |
Set-Content "desktop/src-tauri/tauri.conf.json"
(Get-Content "pyproject.toml") -replace '^version = ".*"', "version = `"$version`"" |
Set-Content "pyproject.toml"
(Get-Content "desktop/package.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" |
Set-Content "desktop/package.json"
Write-Host "Wrote version $version to all version files"
- name: build Windows NVIDIA
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 `
-PackageName StemDeck-Windows-x64.NVIDIA `
-PackageVersion "$env:REF_NAME" `
-StripVenv
- name: build Windows CPU
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 `
-PackageName StemDeck-Windows-x64 `
-PackageVersion "$env:REF_NAME" `
-CpuOnly `
-StripVenv
- name: scan artifacts
run: |
Write-Host "Preparing ClamAV scan for Windows release artifacts..."
Write-Host "Artifacts staged in: $PWD\dist"
Get-ChildItem -Path "dist" -File |
Sort-Object Name |
Select-Object Name,
@{Name="SizeMB"; Expression={ [math]::Round($_.Length / 1MB, 2) }} |
Format-Table -AutoSize
Write-Host "SHA256 checksums:"
Get-ChildItem -Path "dist" -Filter "*.zip" -File |
Sort-Object Name |
ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 $_.FullName
Write-Host " $($hash.Hash) $($_.Name)"
}
Write-Host "Pulling latest ClamAV scanner image..."
docker pull clamav/clamav:latest
Write-Host "Running ClamAV scan over dist/..."
docker run --rm -v "${PWD}/dist:/scan:ro" clamav/clamav:latest `
clamscan --recursive --infected --bell /scan
if ($LASTEXITCODE -ne 0) {
throw "ClamAV scan failed or reported infected files. Exit code: $LASTEXITCODE"
}
Write-Host "ClamAV scan completed successfully. No infected files reported."
- name: upload artifacts
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
dist/StemDeck-Windows-x64.NVIDIA.zip
dist/StemDeck-Windows-x64.NVIDIA.zip.sha256
dist/StemDeck-Windows-x64.zip
dist/StemDeck-Windows-x64.zip.sha256
+83
View File
@@ -0,0 +1,83 @@
# Python bytecode / native artifacts
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Python environments and package caches
.venv/
venv/
env/
.uv-cache/
pip-wheel-metadata/
# Python build outputs
dist/
*.egg-info/
*.egg
.eggs/
# Version is git-derived (hatch-vcs); these are generated artifacts, never committed.
app/_version.py
static/version.json
# Desktop dependencies and build outputs
desktop/node_modules/
desktop/src-tauri/target/
desktop/src-tauri/gen/
# Test, coverage, and type-check caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
.tox/
# Runtime job artifacts and local build scratch
data/
jobs/
settings.json
.run/
.build
# Local environment and secrets
.env
.env.*
!.env.example
# Logs
*.log
logs/
# Local dev scripts
.local/
# Local notes and agent/tool state
.docs
.issues_docs
.claude/
.playwright-mcp/
# Codex: keep repo guidance, ignore local state/cache
.codex/*
.codex/AGENTS.md
.codex/skills/
.codex/skills/**
.codex/cache/
.codex/logs/
.codex/tmp/
# Editors and OS metadata
.idea/
.vscode/
.DS_Store
Thumbs.db
# Tool versions
.python-version
# Imported design references (kept local, not shipped)
design/
+18
View File
@@ -0,0 +1,18 @@
# 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
# DS-0002: Dockerfile missing USER instruction.
# The image intentionally starts as root so the entrypoint script
# (build/docker-entrypoint.sh) can re-chown the bind-mounted /app/jobs
# directory before dropping to the app user (uid 1001) via gosu. The
# process runs as non-root for its entire lifetime after the entrypoint
# executes. Trivy's check does not account for the gosu privilege-drop
# pattern.
DS-0002
+85
View File
@@ -0,0 +1,85 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement privately through the repository's Security tab using the "Report a vulnerability" option (the project's private reporting channel), or by direct message to a project maintainer. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+82
View File
@@ -0,0 +1,82 @@
# Contributing to StemDeck
Thanks for your interest in StemDeck - free, local stem separation for musicians. Contributions of
all kinds are welcome, whether you write code or not.
By participating you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
## Ways to contribute
- **Report a bug** - open a [Bug report](https://github.com/stemdeckapp/stemdeck/issues/new/choose).
Include your OS, the StemDeck version (Help icon -> About), and clear steps to reproduce.
- **Suggest a feature** - open a [Feature request](https://github.com/stemdeckapp/stemdeck/issues/new/choose).
- **Improve the docs** - fixes and clarifications to the README or these guides are always useful.
- **Write code** - bug fixes and features. For anything large, please open an issue or a
[Discussion](https://github.com/stemdeckapp/stemdeck/discussions) first so we can agree on the
approach before you invest time.
- **Found a security issue?** Do not open a public issue - see [SECURITY.md](SECURITY.md).
## Project layout
- `app/` - FastAPI backend (the audio pipeline, job registry, and HTTP API).
- `static/` - the web UI: plain ES-module JavaScript, HTML, and CSS. No build step, no bundler.
- `desktop/` - the Tauri v2 desktop shell (Rust) that wraps the backend + UI.
- `scripts/` - packaging scripts (macOS app/dmg, Windows portable, runtime pack).
- `tests/` - the pytest suite for the backend.
## Development setup
You need [`uv`](https://docs.astral.sh/uv/) and `ffmpeg` on your PATH.
```bash
uv sync --python 3.12 # install the Python environment
./run.sh start # start the backend at http://localhost:8000
```
Other helpers:
```bash
./run.sh restart # restart after backend changes
./run.sh stop # stop the server
./run.sh status # is it running?
```
Open `http://localhost:8000` in your browser to use the app. Frontend changes are picked up on
reload (no build step).
## Before you open a pull request
Please run the same checks CI runs:
```bash
uv run ruff check app/ tests/ # lint
uv run ruff format --check app/ tests/ # formatting
node --check static/js/<changed>.js # syntax-check any JS you touched
uv run pytest tests/ -q # backend tests
```
For Rust changes in `desktop/`, also run `cargo fmt --check` and `cargo clippy -- -D warnings`.
## Pull request guidelines
- Branch off `main`.
- Use [Conventional Commits](https://www.conventionalcommits.org/) for messages
(`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:`).
- Open the PR as a **draft** until it is ready for review.
- Keep PRs focused - one logical change per PR makes review faster.
- Describe what changed and why, and how you tested it.
## Tests
New API endpoints and pipeline stages should come with tests under `tests/`. The suite uses
`pytest` with `httpx.AsyncClient`; see the existing `tests/test_*.py` files for patterns.
## License
StemDeck is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree that your
contributions are licensed under the same terms.
## Questions
Open a [Discussion](https://github.com/stemdeckapp/stemdeck/discussions) or join the
[Discord](https://discord.gg/2MVsWqaPRe). Thanks for helping make StemDeck better.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+429
View File
@@ -0,0 +1,429 @@
<div align="center">
<img src="imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg" alt="StemDeck" width="515" />
**Free, local stem separation. No account. No upload. No subscription.**
<div align="center">
<a href="https://github.com/stemdeckapp/stemdeck/actions/workflows/ci.yml"><img src="https://github.com/stemdeckapp/stemdeck/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/stemdeckapp/stemdeck/stargazers"><img src="https://img.shields.io/github/stars/stemdeckapp/stemdeck?style=flat-square" alt="GitHub Stars"></a>
<a href="https://github.com/stemdeckapp/stemdeck/releases"><img src="https://img.shields.io/github/downloads/stemdeckapp/stemdeck/total?style=flat-square&color=52c65f" alt="Total Downloads"></a>
<a href="https://github.com/stemdeckapp/stemdeck/releases/latest"><img src="https://img.shields.io/github/v/release/stemdeckapp/stemdeck?style=flat-square" alt="Latest Release"></a>
<a href="https://github.com/stemdeckapp/stemdeck/blob/main/LICENSE"><img src="https://img.shields.io/github/license/stemdeckapp/stemdeck?style=flat-square" alt="License"></a>
</div>
<br>
<p align="center"><sub>JOIN THE COMMUNITY</sub></p>
<div align="center">
<a href="https://github.com/stemdeckapp/stemdeck"><img src="https://img.shields.io/badge/GitHub-stemdeckapp-181717?style=flat-square&logo=github&logoColor=white" alt="GitHub"></a>
<a href="https://discord.gg/2MVsWqaPRe"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/StemDeckApp/"><img src="https://img.shields.io/badge/Reddit-r%2FStemDeckApp-FF4500?style=flat-square&logo=reddit&logoColor=white" alt="Reddit"></a>
<a href="https://www.instagram.com/stemdeck"><img src="https://img.shields.io/badge/Instagram-stemdeck-E4405F?style=flat-square&logo=instagram&logoColor=white" alt="Instagram"></a>
<a href="https://x.com/StemDeckApp"><img src="https://img.shields.io/badge/X-StemDeckApp-000000?style=flat-square&logo=x&logoColor=white" alt="X"></a>
<a href="https://stemdeck.app"><img src="https://img.shields.io/badge/Website-stemdeck.app-000000?style=flat-square&logo=safari&logoColor=white" alt="Website"></a>
</div>
</div>
<br>
Drop in an MP3, WAV, or FLAC file, or paste a YouTube URL, and StemDeck splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine.
> **What is this?** StemDeck is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, or FLAC onto the import bar and go. YouTube support is a convenience for content you have the right to process. StemDeck does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine.
> StemDeck is a free, open alternative to cloud stem-splitters like Moises and LALAL.AI: no account, no quota, no uploads, no subscription. If you want stems for personal study and prefer to keep things local and free, StemDeck has you covered. If you need the polish, a mobile app, or deeper musician tooling, the commercial products are a better fit.
![StemDeck screenshot](imgs/screenshot/stemdeck.png)
## We Recommend
StemDeck is free and **does not accept any money, sponsorship, or funding** - not from users, not from anyone listed below. We share these makers and artists purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️
| Name | What they do | Link |
|---|---|---|
| Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) |
| Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) |
| Joao Gaspar | Producer/Film Scorer, Touring/Session Musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
| Kris Luthier | Luthier and Musical Instrument Repair, Lisboa | [@krisluthier](https://www.instagram.com/krisluthier) |
| Thomann | Online Music Store | [@thomann.music](https://www.instagram.com/thomann.music) |
| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |
| Empress Effects | Effects pedals | [empresseffects.com](https://empresseffects.com) |
---
## Features
**6-stem separation** via Demucs `htdemucs_6s`, with auto-detection of the best Torch device (CUDA on NVIDIA, MPS on Apple Silicon, CPU fallback).
**YouTube and local file import.** Paste a YouTube URL or drop an MP3 or WAV directly onto the import bar.
**DAW-style waveform editor** with min/max sample rendering across all stems, shared normalization, zoom in/out/Fit, loop drag on the ruler, gold playhead overlay, and stem-aligned lanes.
**Stem subset extraction.** Click stem chips to choose which stems to keep. Clicking from "all selected" snaps to "only this one"; subsequent clicks add or remove.
**"Original" backing track.** When you pick a subset, a 7th lane contains the complement (full song minus selected stems), perfect for A/B reference without doubling.
**Downloadable selected mix.** A single `mix.wav` of just your selected stems, summed via ffmpeg amix.
**Per-stem mixer** with volume fader, mute, solo, and "monitor" (solo-only) per stem. State syncs between the preview mixer and the stems sidebar.
**Live VU meters** per stem. Post-gain RMS via Web Audio analysers with peak hold and slow falloff.
**Song analysis** including BPM (librosa beat tracker), key, scale, and confidence (Albrecht-Shanahan profiles), integrated LUFS (BS.1770), and sample peak in dBFS.
**Cancellable jobs.** Cancel mid-pipeline and the runner terminates the active subprocess immediately, deletes the partial job dir, and returns to ready.
**Library panel** with folder-based track organisation, drag-and-drop, search, and trash.
---
## Honest Comparison
StemDeck is not trying to compete with commercial stem-separation products. It covers the core use case well and stops there. This table exists so you can make an informed choice rather than discover the gaps after the fact.
| | StemDeck | Moises / LALAL.AI / similar |
|---|---|---|
| **Price** | Free, forever | Freemium; credits or subscription required for regular use |
| **Hosting** | Runs entirely on your machine | Cloud; audio must be uploaded to their servers |
| **Account / login** | None | Required |
| **Internet required** | Only for YouTube download and first model fetch (~170 MB, cached after) | Always; no offline use |
| **Privacy** | Audio never leaves your machine | Audio is uploaded and processed on third-party servers |
| **Data retention** | You control it; delete anytime | Governed by their privacy policy and retention period |
| **Stem model** | Demucs `htdemucs_6s` (open source, Meta AI) | Proprietary models, regularly updated, generally higher quality |
| **Stem count** | 6 (vocals, drums, bass, guitar, piano, other) | Up to 10 depending on service and plan |
| **Input formats** | YouTube URL, MP3, WAV | MP3, WAV, FLAC, M4A, and more depending on service |
| **Processing speed** | Depends on your hardware; fast with a GPU, slow on CPU only | Fast regardless of your hardware (runs on their servers) |
| **Batch processing** | One job at a time | Yes, on paid plans |
| **Mobile app** | No | iOS and Android |
| **Extra features** | No (no pitch shift, chord detection, lyrics, click track, BPM tap) | Yes, varies by product |
| **Polish** | Functional, hobby-grade UI | Polished, production-grade apps |
| **Source code** | Open source, forkable, self-hostable | Closed source |
If you need speed, quality, mobile access, or the extra musician tooling, the commercial products are worth the money. If you want stems for personal study, prefer to keep audio private, or just want something that runs locally with no strings attached, StemDeck is enough.
---
## Download
Pre-built installers and zips are attached to each [GitHub Release](https://github.com/stemdeckapp/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 |
|---|---|---|
| `StemDeck-Windows-x64.zip` | CPU only | ~700 MB |
| `StemDeck-Windows-x64.NVIDIA.zip` | NVIDIA CUDA | ~1.6 GB |
Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB). Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required.
---
## Technologies
<div align="center">
<img src="https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6?style=flat-square&logo=windows" alt="Platform">
<img src="https://img.shields.io/badge/Powered_by-Demucs-FF6B35?style=flat-square" alt="Powered by Demucs">
<img src="https://img.shields.io/badge/CI-GitHub_Actions-2088FF?style=flat-square&logo=github-actions&logoColor=white" alt="CI: GitHub Actions">
</div>
<br>
StemDeck is built on **[Python 3.12](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 Native App
Requires Rust, Node.js, and Python 3.12. 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
open desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app
```
If macOS blocks the app with a Gatekeeper prompt, run:
```sh
xattr -dr com.apple.quarantine desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app
```
> **Note:** To test a clean first-launch during development, you can wipe previous app data first: `rm -rf ~/Library/Application\ Support/StemDeck`. Don't do this on a real install.
---
### Web Server (macOS / Linux / Windows with Python 3.12+)
#### Prerequisites
Python 3.12 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)
```sh
git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck
./run.sh setup # installs ffmpeg + uv, runs uv sync
./run.sh start
```
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`.
#### Windows (PowerShell)
Install prerequisites:
- [uv](https://docs.astral.sh/uv/getting-started/installation/) — `winget install astral-sh.uv`
- [ffmpeg](https://ffmpeg.org/download.html) — `winget install Gyan.FFmpeg` (or Chocolatey: `choco install ffmpeg`)
```powershell
git clone https://github.com/stemdeckapp/stemdeck stemdeck; cd stemdeck
uv sync
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
```
Open <http://localhost:8000>.
> `run.sh` is macOS/Linux only. On Windows use the PowerShell commands above, or run inside WSL.
**NVIDIA GPU (CUDA):** install the CUDA-enabled torch build before starting:
```powershell
uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
$env:STEMDECK_DEMUCS_DEVICE = "cuda"
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
```
---
#### Manual (any platform)
```sh
git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck
uv sync
uv run uvicorn app.main:app --reload
```
#### 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.
A prebuilt image is published to GHCR. Tags: `edge` (rolling, rebuilt on every merge to main), `latest` (newest stable release), and `X.Y.Z` (pinned to a release).
```sh
docker run -d --name stemdeck -p 8000:8000 \
-v /path/to/jobs:/app/jobs \
-v /path/to/cache:/cache \
-e STEMDECK_PERSIST_LIBRARY=1 \
ghcr.io/stemdeckapp/stemdeck:edge
```
On a Linux host with an NVIDIA GPU (driver + NVIDIA Container Toolkit installed), add `--runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=all` and StemDeck auto-detects CUDA. The image already bundles CUDA-enabled torch, so no separate CUDA install is needed.
#### Unraid
StemDeck is available in Unraid Community Applications: open **Apps**, search "StemDeck", and install. Map the two volumes to persistent appdata paths:
- `/app/jobs` -> `/mnt/user/appdata/stemdeck/jobs` (library + stems)
- `/cache` -> `/mnt/user/appdata/stemdeck/cache` (model weights)
The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks are never auto-deleted. For GPU acceleration, install the **Nvidia Driver** plugin, then set the container's Extra Parameters to `--runtime=nvidia` (the `NVIDIA_VISIBLE_DEVICES` and `NVIDIA_DRIVER_CAPABILITIES` variables are already in the template). CPU-only works with no extra configuration.
#### `run.sh` control script
```sh
./run.sh setup # one-shot: install ffmpeg + uv, then uv sync
./run.sh start # boots uvicorn in the background
./run.sh stop # graceful shutdown
./run.sh restart # stop + start
./run.sh status # is it running?
```
---
## How to Use
1. On the import bar, click stem chips to choose which stems to extract (defaults to all 6).
2. Paste a YouTube URL **or** drop an MP3/WAV file, then click **Process**.
3. Wait through `Uploading...` / `Downloading...``Analyzing...``Separating...``Mixing tracks...`.
4. When done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (full song minus your selection); the rest are your isolated stems.
5. Mix: **Play/Pause/Stop** controls the master transport. **M** mutes a stem, **S** solos it (additive; multiple solos stay audible), **Monitor** solos only that stem and clears others. The volume fader moves 1:1 with drag; double-click resets to 0 dB; `Shift+wheel` gives coarse adjustment and plain wheel gives fine. The **Reset**, **Mute**, and **Solo** toolbar buttons act on all stems at once.
6. Drag on the ruler to define a loop region; click `Loop` to enable. Use `+` / `-` / `Fit` or `Ctrl/Cmd+wheel` to zoom.
7. **Download Mix** in the footer gives you a WAV of your selected stems summed together.
**Keyboard shortcuts:** `Space` play/pause · `[` seek -5s · `]` seek +5s · `L` loop · `I` loop in · `O` loop out
---
## Configuration
| Variable | Default | Purpose |
|---|---|---|
| `STEMDECK_DEMUCS_DEVICE` | auto | Force Torch device: `cuda`, `mps`, or `cpu`. |
| `STEMDECK_DEMUCS_MODEL` | `htdemucs_6s` | Demucs model name. |
| `STEMDECK_JOBS_DIR` | `./jobs` | Where job directories land. |
| `STEMDECK_DATA_DIR` | (none) | Portable mode root; sets all sub-dirs below to live inside it. |
| `STEMDECK_CACHE_DIR` | `<data>/cache` | Torch model cache directory. |
| `STEMDECK_DOWNLOADS_DIR` | `<data>/downloads` | yt-dlp download scratch space. |
| `STEMDECK_MODELS_DIR` | `<data>/models` | Demucs model weights directory. |
| `STEMDECK_LOGS_DIR` | `<data>/logs` | Log file output directory. |
| `STEMDECK_FFMPEG_DIR` | (none) | Directory containing a bundled ffmpeg binary. |
| `STEMDECK_FFMPEG` | `ffmpeg` | Path to the ffmpeg executable. |
| `STEMDECK_FFPROBE` | `ffprobe` | Path to the ffprobe executable. |
| `STEMDECK_MAX_DURATION_SEC` | `1200` | Reject audio longer than this (seconds). |
| `STEMDECK_JOB_TTL_SECONDS` | `86400` | How long to keep job dirs on disk. |
| `STEMDECK_MAX_PENDING_JOBS` | `3` | Max queued jobs before returning 503. |
| `STEMDECK_TIMEOUT_FFMPEG` | `300` | ffmpeg subprocess timeout (seconds). |
| `STEMDECK_TIMEOUT_ANALYZE` | `120` | Audio analysis timeout (seconds). |
| `STEMDECK_TIMEOUT_DEMUCS_STALL` | `1800` | Kill Demucs if no output for this many seconds. |
`run.sh` also reads: `HOST` (default `127.0.0.1`), `PORT` (default `8765`), `RELOAD=1` (enable uvicorn auto-reload for development), `FOREGROUND=1` (run in foreground instead of backgrounding).
---
## API
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/health` | Server health and version info |
| POST | `/api/jobs` | JSON `{url, stems?}` or multipart `file + stems``{job_id}` |
| GET | `/api/jobs` | List completed (library) jobs |
| GET | `/api/jobs/{id}` | Job state snapshot |
| GET | `/api/jobs/{id}/events` | SSE stream of job state |
| POST | `/api/jobs/{id}/cancel` | Terminate active subprocess and cancel job |
| PATCH | `/api/jobs/{id}/sections` | Save waveform section markers for a job |
| GET | `/api/jobs/{id}/stems/{name}.wav` | Stream a single stem WAV file |
| GET | `/api/jobs/{id}/stems/{name}.mp3` | Transcode and stream a stem as MP3 |
| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into an MP4 |
| DELETE | `/api/jobs/{id}` | Remove job dir from disk (terminal jobs only) |
---
## Troubleshooting
**`ffmpeg: command not found`:** install ffmpeg and restart with `./run.sh restart`.
**`WARNING: [youtube] No supported JavaScript runtime`:** install deno (`brew install deno` on macOS) and restart. Downloads still work without it but may pick suboptimal formats.
**First separation is very slow:** Demucs downloads `htdemucs_6s` weights (~170 MB) on first run; cached afterwards.
**Demucs runs on CPU only:** check the startup log for `device=mps` or `device=cuda`. If you see `cpu`, your torch install may be CPU-only.
**Page reloaded mid-job:** the job keeps running server-side. Wait for it to finish, then resubmit.
**`./run.sh: Permission denied`:** run `chmod +x run.sh`.
---
## Layout on Disk
```
jobs/<job_id>/
└── stems/
├── vocals.wav # the 6 Demucs stems (always present)
├── drums.wav
├── bass.wav
├── guitar.wav
├── piano.wav
├── other.wav
├── original.wav # sum of un-selected stems (subset only)
└── mix.wav # ffmpeg amix of selected stems (subset only)
```
Job state is in-memory. Restart the server and the job list resets, but files persist on disk. Old dirs are swept automatically (TTL 24 h, configurable).
---
## Disclaimer
StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. It does not store, cache, or redistribute any audio content. All processing runs on the user's own machine and no audio is transmitted anywhere.
YouTube URL support is provided via [yt-dlp](https://github.com/yt-dlp/yt-dlp) as a convenience. Automated downloading may violate YouTube's Terms of Service. You, the user, are solely responsible for ensuring you have the right to process any audio you submit, complying with the terms of service of any site you download from, and respecting the copyright of the material you work with.
You are also responsible for following the licenses of the underlying tools this project depends on (yt-dlp, Demucs, FFmpeg, PyTorch, and others listed in `pyproject.toml`).
The author(s) of StemDeck provide this software "as is", without warranty of any kind, and accept no responsibility or liability for how it is used.
---
## Community
| Platform | Link |
|---|---|
| GitHub | [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) |
| Discord | [discord.gg/2MVsWqaPRe](https://discord.gg/2MVsWqaPRe) |
| Reddit | [r/StemDeckApp](https://www.reddit.com/r/StemDeckApp/) |
| Instagram | [@stemdeck](https://www.instagram.com/stemdeck) |
| X | [@StemDeckApp](https://x.com/StemDeckApp) |
| Website | [stemdeck.app](https://stemdeck.app) *(coming soon)* |
---
## Environment Variables
These are for development and testing. Release builds only recognize the variables marked "release".
| Variable | Platform | Scope | Description |
|---|---|---|---|
| `STEMDECK_DATA_DIR` | all | release | Override the user data directory (default: platform-standard location) |
| `STEMDECK_ROOT` | all | release | Override the app root directory (default: derived from executable path) |
| `STEMDECK_PYTHON` | all | **debug builds only** | Override the Python executable path |
| `STEMDECK_FFMPEG_URL` | Windows, macOS | release | Override the FFmpeg download URL |
| `STEMDECK_FFPROBE_URL` | macOS | release | Override the ffprobe download URL |
---
## Contributing
Issues, feature suggestions, and pull requests are welcome. See open issues for what's planned.
---
## Star History
<a href="https://www.star-history.com/?repos=stemdeckapp%2Fstemdeck&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=stemdeckapp/stemdeck&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=stemdeckapp/stemdeck&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=stemdeckapp/stemdeck&type=date&legend=top-left" />
</picture>
</a>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`stemdeckapp/stemdeck`
- 原始仓库:https://github.com/stemdeckapp/stemdeck
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+53
View File
@@ -0,0 +1,53 @@
# Security Policy
## Supported Versions
StemDeck is in active alpha. Only the latest release receives security fixes -
there are no long-term-support branches yet. Please update to the newest
release before reporting an issue.
| Version | Supported |
| --------------- | --------- |
| Latest release | Yes |
| Any older build | No |
## Reporting a Vulnerability
Please report security issues privately, not in a public issue. Open the
repository's **Security** tab and click **Report a vulnerability** (GitHub
Private Vulnerability Reporting). This keeps the details private until a fix is
available.
Include where you can:
- Affected version and operating system
- Steps to reproduce
- Impact (what an attacker could do)
- Any relevant logs or proof of concept
What to expect:
- Acknowledgement within about 5 business days (best-effort; small team).
- We confirm the report, assess severity, and keep you updated.
- Fixes ship in the next release. We credit reporters unless you prefer not to
be named.
## Scope and threat model
StemDeck is local-first and single-user by design: it runs on your own machine,
has no authentication, and is same-origin only. Reports that it "has no login"
or "no per-user access control" describe intended behavior, not vulnerabilities.
We are most interested in reports about:
- Malicious media files or URLs (SSRF, command or argument injection)
- Cross-site scripting (XSS) or Content-Security-Policy bypass in the desktop
webview
- Path traversal in the backend file APIs
- Integrity of downloaded binaries (FFmpeg, the runtime pack)
## Good-faith research
We will not pursue action against good-faith security research that respects
user privacy and avoids data destruction or service disruption. Thank you for
helping keep StemDeck users safe.
View File
View File
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.config import STEM_NAMES
router = APIRouter()
@router.get("/config")
def get_config() -> dict:
return {"stem_names": list(STEM_NAMES)}
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.core.config import JOB_ID_RE
from app.core.registry import get as registry_get
router = APIRouter(tags=["events"])
# Close SSE connections that outlive this threshold to prevent zombie
# connections from accumulating when clients disconnect without a TCP RST.
_MAX_SSE_SECONDS = 4 * 3600 # 4 hours
# Hard cap on concurrent SSE connections to prevent resource exhaustion
# from tab leaks or aggressive reconnect loops.
_MAX_SSE_CONNECTIONS = 200
# Counter is only mutated from the async event loop (no awaits between
# check+increment), so no lock is needed.
_sse_active = 0
@router.get("/jobs/{job_id}/events")
async def job_events(job_id: str) -> StreamingResponse:
"""Server-Sent Events stream of job state updates. Closes when the job
reaches a terminal status (done, error, cancelled) or after 4 hours."""
global _sse_active
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if _sse_active >= _MAX_SSE_CONNECTIONS:
raise HTTPException(status_code=503, detail="too many concurrent streams")
_sse_active += 1
async def stream() -> AsyncIterator[str]:
global _sse_active
try:
last = None
keepalive_at = 0
loop = asyncio.get_running_loop()
deadline = loop.time() + _MAX_SSE_SECONDS
while loop.time() < deadline:
snapshot = job.to_state()
serialized = json.dumps(snapshot)
if serialized != last:
yield f"data: {serialized}\n\n"
last = serialized
keepalive_at = 0
if snapshot["status"] in ("done", "error", "cancelled"):
return
keepalive_at += 1
if keepalive_at >= 75: # ~15s
yield ": keepalive\n\n"
keepalive_at = 0
await asyncio.sleep(0.2)
finally:
_sse_active -= 1
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
import shutil
import subprocess
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, field_validator
from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_JOBS, STEM_NAMES, ffprobe_executable
from app.core.models import Job
from app.core.registry import all_jobs as registry_all_jobs
from app.core.registry import get as registry_get
from app.core.registry import get_proc as registry_get_proc
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
from app.core.settings import get_max_duration_sec
from app.pipeline import run_local_pipeline, run_pipeline
from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url
router = APIRouter(tags=["jobs"])
logger = logging.getLogger("stemdeck.api")
_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a"))
_MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB
_WS_RE = re.compile(r"\s+")
def _sanitize_title(filename: str) -> str:
"""Strip extension, normalize whitespace, cap at 120 chars."""
stem = Path(filename).stem
return _WS_RE.sub(" ", stem).strip()[:120]
def _probe_duration(path: Path) -> float:
"""Run ffprobe to get file duration in seconds."""
result = subprocess.run(
[
ffprobe_executable(),
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}")
try:
return float(result.stdout.strip())
except ValueError as e:
raise RuntimeError(f"ffprobe returned non-numeric duration: {result.stdout!r}") from e
def _check_file_size(file_obj: object) -> int:
"""Seek to end, return size, rewind. Operates on the SpooledTemporaryFile
backing a starlette UploadFile — synchronous, suitable for to_thread."""
file_obj.seek(0, 2) # type: ignore[union-attr]
size = file_obj.tell() # type: ignore[union-attr]
file_obj.seek(0) # type: ignore[union-attr]
return size
def _copy_to_dest(src_file: object, dest: Path) -> None:
"""Copy SpooledTemporaryFile contents to dest. Synchronous, run in thread."""
with dest.open("wb") as out:
shutil.copyfileobj(src_file, out) # type: ignore[arg-type]
def _rmtree_job(job_id: str) -> None:
job_dir = JOBS_DIR / job_id
if not job_dir.is_dir():
return
try:
shutil.rmtree(job_dir)
except Exception:
logger.warning("failed to remove job dir %s", job_dir, exc_info=True)
def _task_error_cb(task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error("pipeline task raised unhandled exception", exc_info=exc)
class JobRequest(BaseModel):
url: str
# Subset of stems to include in the post-processing "selected mix"
# audio file. None = all 6 (no extra mix produced; would equal the
# original). Unknown stem names are dropped silently rather than
# rejected, so a future model with extra stems doesn't break older
# clients pinning the old set.
stems: list[str] | None = None
@router.post("")
async def create_job(request: Request) -> dict[str, str]:
"""Submit a YouTube URL (JSON body) or upload an audio file (multipart/form-data)
to start a stem-separation job. Returns the new job ID."""
ct = request.headers.get("content-type", "")
if "multipart/form-data" in ct:
return await _create_local_job(request)
return await _create_youtube_job(request)
async def _create_youtube_job(request: Request) -> dict[str, str]:
try:
body = await request.json()
except Exception as e:
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") from e
try:
payload = JobRequest(**body)
except Exception as e:
raise HTTPException(status_code=422, detail=str(e)) from e
try:
url = validate_youtube_url(payload.url)
except InvalidYouTubeURL as e:
raise HTTPException(status_code=422, detail=str(e)) from e
selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES)
if not selected:
selected = list(STEM_NAMES)
job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url)
if not registry_register_if_capacity(job, MAX_PENDING_JOBS):
raise HTTPException(status_code=503, detail="Server busy, please try again later")
task = asyncio.create_task(run_pipeline(job, url, JOBS_DIR))
task.add_done_callback(_task_error_cb)
return {"job_id": job.id}
async def _create_local_job(request: Request) -> dict[str, str]:
# Fast pre-check: if already at capacity, reject before touching disk.
# The real atomic check happens in register_if_capacity after the upload.
if sum(1 for j in registry_all_jobs().values() if j.status == "queued") >= MAX_PENDING_JOBS:
raise HTTPException(status_code=503, detail="Server busy, please try again later")
# Quick pre-check on Content-Length to fail fast for obviously oversized
# uploads without buffering the whole body first.
cl_header = request.headers.get("content-length")
if cl_header:
try:
if int(cl_header) > _MAX_UPLOAD_BYTES + 4096:
raise HTTPException(status_code=422, detail="File exceeds 400 MB limit")
except ValueError:
pass
form = await request.form()
upload = form.get("file")
stems_raw = form.get("stems", "[]")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(status_code=422, detail="No file provided")
filename: str = getattr(upload, "filename", "") or ""
ext = Path(filename).suffix.lower()
if ext not in _ALLOWED_EXTS:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, and .m4a",
)
# Validate stems list from form field
try:
stems_list = json.loads(stems_raw)
if not isinstance(stems_list, list):
raise ValueError
except (json.JSONDecodeError, ValueError):
stems_list = []
selected = [s for s in stems_list if s in STEM_NAMES] or list(STEM_NAMES)
# Check actual file size (SpooledTemporaryFile is already buffered at this
# point; seek/tell are fast and don't re-read the body).
file_obj = upload.file # type: ignore[union-attr]
file_size = await asyncio.to_thread(_check_file_size, file_obj)
if file_size == 0:
raise HTTPException(status_code=422, detail="Uploaded file is empty")
if file_size > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=422, detail="File exceeds 400 MB limit")
job_id = uuid.uuid4().hex[:12]
job_dir = JOBS_DIR / job_id
source_path = job_dir / f"source{ext}"
job_dir.mkdir(parents=True, exist_ok=True)
try:
await asyncio.to_thread(_copy_to_dest, file_obj, source_path)
# Duration check before registering the job so a violation leaves no
# registered job and no leftover directory.
try:
duration = await asyncio.to_thread(_probe_duration, source_path)
except Exception as e:
raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e
max_duration = get_max_duration_sec()
if duration > max_duration:
raise HTTPException(
status_code=422,
detail=(f"File is {int(duration // 60)} min — limit is {max_duration // 60} min"),
)
except HTTPException:
shutil.rmtree(job_dir, ignore_errors=True)
raise
title = _sanitize_title(filename)
local_source_url = f"local:{title}"
job = Job(
id=job_id,
selected_stems=selected,
title=title,
duration_sec=duration,
source_url=local_source_url,
)
if not registry_register_if_capacity(job, MAX_PENDING_JOBS):
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(status_code=503, detail="Server busy, please try again later")
task = asyncio.create_task(run_local_pipeline(job, source_path, JOBS_DIR))
task.add_done_callback(_task_error_cb)
return {"job_id": job.id}
@router.get("")
def list_jobs() -> list[dict]:
"""List all completed jobs in the library, sorted by creation time."""
return [
job.to_state()
for job in sorted(registry_all_jobs().values(), key=lambda j: j.created_at)
if job.status == "done"
]
@router.get("/{job_id}")
def get_job(job_id: str) -> dict:
"""Get the current state of a job by ID."""
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return job.to_state()
@router.post("/{job_id}/cancel")
def cancel_job(job_id: str) -> dict:
"""Request cancellation of a running job. Idempotent for terminal jobs."""
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status in ("done", "error", "cancelled"):
return job.to_state()
job.cancel_requested = True
proc = registry_get_proc(job_id)
if proc is not None and proc.poll() is None:
proc.terminate()
return job.to_state()
_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
class SectionItem(BaseModel):
id: str
name: str
start: float
end: float
color: str
@field_validator("id")
@classmethod
def _check_id(cls, v: str) -> str:
if not _SECTION_ID_RE.match(v):
raise ValueError("invalid section id")
return v
@field_validator("name")
@classmethod
def _check_name(cls, v: str) -> str:
return v.strip()[:64] or "Section"
@field_validator("color")
@classmethod
def _check_color(cls, v: str) -> str:
if not _COLOR_RE.match(v):
raise ValueError("invalid color")
return v
@field_validator("start", "end")
@classmethod
def _check_time(cls, v: float) -> float:
if not (0 <= v < 86400):
raise ValueError("time out of range")
return round(v, 3)
class SectionsBody(BaseModel):
sections: list[SectionItem]
@router.patch("/{job_id}/sections")
def update_sections(job_id: str, body: SectionsBody) -> dict:
"""Save named timeline sections (intro, verse, chorus, etc.) for a done job."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
validated = [s.model_dump() for s in body.sections]
job.sections = validated
job_dir = (JOBS_DIR / job_id).resolve()
if not job_dir.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="job not found")
meta_path = job_dir / "metadata.json"
meta: dict = {}
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
meta["sections"] = validated
try:
meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.exception("failed to write sections for %s: %s", job_id, exc)
raise HTTPException(status_code=500, detail="failed to save sections") from exc
registry_persist(JOBS_DIR)
return {"job_id": job_id, "sections": validated}
@router.delete("/{job_id}")
def delete_job(job_id: str) -> dict[str, str]:
"""Delete a completed or failed job and remove its stem files from disk."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in ("done", "error", "cancelled"):
raise HTTPException(status_code=409, detail="job is still running")
_rmtree_job(job_id)
registry_remove(job_id)
registry_persist(JOBS_DIR)
return {"job_id": job_id, "status": "deleted"}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import io
import segno
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
router = APIRouter()
_MAX_LEN = 500
@router.get("/qr")
def get_qr(url: str) -> Response:
if not url or len(url) > _MAX_LEN:
raise HTTPException(status_code=422, detail="url required (max 500 chars)")
qr = segno.make_qr(url, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=5, border=2, dark="#1a1206", light="#ffffff")
return Response(
content=buf.getvalue(),
media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=3600"},
)
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.config import router as config_router
from app.api.events import router as events_router
from app.api.jobs import router as jobs_router
from app.api.qr import router as qr_router
from app.api.stems import router as stems_router
router = APIRouter()
router.include_router(config_router, tags=["config"])
router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
router.include_router(events_router, tags=["events"])
router.include_router(stems_router, tags=["stems"])
router.include_router(qr_router, tags=["qr"])
+494
View File
@@ -0,0 +1,494 @@
from __future__ import annotations
import asyncio
import logging
import os
import re
import subprocess
import tempfile
import uuid
import zipfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse
from starlette.background import BackgroundTask
from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable
from app.core.registry import get as registry_get
logger = logging.getLogger("stemdeck.api")
router = APIRouter(tags=["stems"])
# Stem files served by this endpoint: the 6 demucs stems + two
# pipeline-produced extras. "original" is the re-encoded source song
# (added when the user picked a strict subset), "mix" is the ffmpeg
# amix of the user's selected stems.
_ALLOWED_NAMES = frozenset(STEM_NAMES) | {"original", "mix"}
# Lanes the dynamic mixdown may sum: the 6 stems plus "original" (the complement
# track shown when the user picked a subset). "mix" is excluded -- it is the
# static pre-render this endpoint replaces. Gains are linear; the studio caps a
# lane at 2.0, so this generous bound just rejects abusive values.
_MIXDOWN_NAMES = frozenset(STEM_NAMES) | {"original"}
_MIXDOWN_MAX_GAIN = 4.0
# Output encoders by container/extension, shared by the dynamic mixdown and the
# stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps.
_ENCODE_ARGS = {
"wav": ["-c:a", "pcm_s16le"],
"mp3": ["-q:a", "2"],
"flac": ["-c:a", "flac"],
}
MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()}
MIXDOWN_MEDIA_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac"}
def _validate_stem_path(job_id: str, name: str):
"""Shared guard: validate job_id, name, job state, and path. Returns resolved Path."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
if name not in _ALLOWED_NAMES:
raise HTTPException(status_code=404, detail="unknown stem")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve()
if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="stem not found")
return path
def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
"""Parse and validate parallel comma-separated lane names and linear gains.
Shared by the audio mixdown and the MP4 video mux. Raises HTTPException
on malformed input, unknown lanes, or out-of-range gains."""
names = [s for s in stems.split(",") if s]
raw_gains = [g for g in gains.split(",") if g]
if not names or len(names) != len(raw_gains):
raise HTTPException(
status_code=422, detail="stems and gains must be non-empty and equal length"
)
try:
parsed_gains = [float(g) for g in raw_gains]
except ValueError:
raise HTTPException(status_code=422, detail="gains must be numbers") from None
if any(g < 0 or g > _MIXDOWN_MAX_GAIN for g in parsed_gains):
raise HTTPException(status_code=422, detail="gain out of range")
if not set(names) <= _MIXDOWN_NAMES:
raise HTTPException(status_code=422, detail="unknown stem requested")
return names, parsed_gains
async def _stream_ffmpeg(cmd: list[str]):
"""Yield ffmpeg stdout in 64 KB chunks; kill process on client disconnect."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
break
yield chunk
finally:
if proc.returncode is None:
proc.kill()
await proc.wait()
async def _ensure_cached_mp3(src: Path) -> Path:
"""Transcode `src` (a stem WAV) to a sibling `<name>.mp3`, cached on disk.
Re-encoding a full song on every request is the slow part of loading a track
on mobile (≈3s/stem × 6 in parallel); caching makes repeat loads instant.
Written atomically (temp + rename) so concurrent fetches can't serve a
partial file."""
dest = src.with_suffix(".mp3")
if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime:
return dest
tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp")
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-q:a",
"2", # VBR ~190 kbps
"-f",
"mp3",
str(tmp),
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG)
except (TimeoutError, asyncio.TimeoutError):
proc.kill()
await proc.wait()
tmp.unlink(missing_ok=True)
raise HTTPException(status_code=504, detail="mp3 transcode timed out") from None
if proc.returncode != 0:
tmp.unlink(missing_ok=True)
logger.warning(
"mp3 transcode failed for %s: %s", src.name, (stderr or b"").decode("utf-8", "replace")
)
raise HTTPException(status_code=500, detail="mp3 transcode failed")
os.replace(tmp, dest)
return dest
@router.get("/jobs/{job_id}/stems/peaks.json")
async def get_stem_peaks(job_id: str) -> Response:
"""Return pre-computed waveform peaks for all stems."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
path = (JOBS_DIR / job_id / "stems" / "peaks.json").resolve()
if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="peaks not found")
return FileResponse(
path,
media_type="application/json",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@router.api_route("/jobs/{job_id}/stems/{name}.wav", methods=["GET", "HEAD"], response_model=None)
async def get_stem(
job_id: str,
name: str,
start: float | None = Query(default=None, ge=0, description="Trim start in seconds"),
end: float | None = Query(default=None, gt=0, description="Trim end in seconds"),
) -> FileResponse | StreamingResponse:
"""Download a WAV stem. Optional ?start=&end= trims to a time region."""
path = _validate_stem_path(job_id, name)
if start is None and end is None:
return FileResponse(path, media_type="audio/wav", filename=f"{name}.wav")
if start is None or end is None or start >= end:
raise HTTPException(
status_code=422,
detail="start and end are both required and start must be less than end",
)
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-ss",
str(start),
"-i",
str(path),
"-t",
str(end - start),
"-c:a",
"pcm_s16le",
"-f",
"wav",
"pipe:1",
]
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{name}_region.wav"'},
)
@router.get("/jobs/{job_id}/stems/{name}.mp3")
async def get_stem_mp3(
job_id: str,
name: str,
start: float | None = Query(default=None, ge=0, description="Trim start in seconds"),
end: float | None = Query(default=None, gt=0, description="Trim end in seconds"),
) -> Response:
"""Stem as MP3 (VBR ~190 kbps). Full stems are cached to disk; ?start=&end=
streams a freshly-trimmed region (uncached)."""
path = _validate_stem_path(job_id, name)
if (start is None) != (end is None) or (start is not None and start >= end):
raise HTTPException(
status_code=422,
detail="start and end are both required and start must be less than end",
)
# Full-stem requests (no trim) are cached to disk so repeat loads — the
# common case for the mobile player — are instant instead of re-encoding.
if start is None:
cached = await _ensure_cached_mp3(path)
return FileResponse(
cached,
media_type="audio/mpeg",
headers={
"Content-Disposition": f'attachment; filename="{name}.mp3"',
# Stems are immutable once a job is done — let the phone cache
# them so a re-load is instant and offline-friendly.
"Cache-Control": "public, max-age=31536000, immutable",
},
)
pre_seek = ["-ss", str(start)] if start is not None else []
post_seek = ["-t", str(end - start)] if start is not None else []
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
*pre_seek,
"-i",
str(path),
*post_seek,
"-q:a",
"2", # VBR ~190 kbps
"-f",
"mp3",
"pipe:1",
]
filename = f"{name}_region.mp3" if start is not None else f"{name}.mp3"
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/jobs/{job_id}/mixdown.{ext}", response_model=None)
async def get_mixdown(
job_id: str,
ext: str,
stems: str = Query(..., description="Comma-separated lane names to sum"),
gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
start: float | None = Query(default=None, ge=0, description="Trim start in seconds"),
end: float | None = Query(default=None, gt=0, description="Trim end in seconds"),
) -> StreamingResponse:
"""Render a fresh mixdown of the given lanes at the given gains, streamed as
WAV or MP3. Mirrors the studio mixer (per-stem volume, mute, solo) so the
exported file matches what is heard. The master fader is intentionally not
applied -- it is a monitoring level, not part of the mix. Optional ?start=&end=
trims to a loop region."""
if ext not in ("wav", "mp3", "flac"):
raise HTTPException(status_code=404, detail="not found")
names, parsed_gains = _parse_lane_gains(stems, gains)
if (start is None) != (end is None) or (start is not None and start >= end):
raise HTTPException(
status_code=422,
detail="start and end are both required and start must be less than end",
)
# Validates job_id (404), job done (404), and path traversal (404) per stem.
paths = [_validate_stem_path(job_id, name) for name in names]
pre_seek = ["-ss", str(start)] if start is not None else []
post_seek = ["-t", str(end - start)] if start is not None else []
cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
for p in paths:
cmd += [*pre_seek, "-i", str(p)]
# Apply each lane's gain, then sum with amix (normalize=0 keeps levels faithful,
# matching collect.py). A single audible lane skips amix (a 1-input amix is a no-op).
filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)]
n = len(paths)
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
out_label = "[mix]"
else:
out_label = "[a0]"
codec = MIXDOWN_CODECS[ext]
cmd += ["-filter_complex", ";".join(filters), "-map", out_label, *post_seek, *codec, "pipe:1"]
media_type = MIXDOWN_MEDIA_TYPES[ext]
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="mixdown.{ext}"'},
)
def _safe_title(title: str | None) -> str:
"""Sanitize a song title into a filename-safe slug (matches the frontend)."""
safe = re.sub(r"[^a-zA-Z0-9]+", "_", title or "")
safe = re.sub(r"_{2,}", "_", safe).strip("_")[:80].strip("_")
return safe or "stems"
@router.get("/jobs/{job_id}/video.mp4", response_model=None)
async def get_video_mixdown(
job_id: str,
stems: str = Query(..., description="Comma-separated lane names to sum"),
gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
) -> StreamingResponse:
"""Mux a fresh audio mixdown of the current mixer state with the job's preserved
video into an MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded
as AAC) and stream-copies video.mp4 -- the silent video kept from an .mp4 upload
or the real video stream downloaded for a YouTube job. 404 when the job has no
video (SoundCloud / plain audio uploads).
Streamed as fragmented MP4 (frag_keyframe+empty_moov) since the output pipe is
not seekable -- +faststart would require a seekable file. The full song is
exported; no region trim, to avoid A/V drift from stream-copy seeking."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
video_path = (JOBS_DIR / job_id / "video.mp4").resolve()
if not video_path.is_file() or not video_path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="no video track for this job")
names, parsed_gains = _parse_lane_gains(stems, gains)
# Validates job_id (404), job done (404), and path traversal (404) per stem.
paths = [_validate_stem_path(job_id, name) for name in names]
cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
for p in paths:
cmd += ["-i", str(p)]
cmd += ["-i", str(video_path)]
video_idx = len(paths)
# Per-lane gain then amix (normalize=0 keeps levels faithful). A single audible
# lane skips amix (a 1-input amix is a no-op), matching get_mixdown.
filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)]
n = len(paths)
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
out_label = "[mix]"
else:
out_label = "[a0]"
cmd += [
"-filter_complex",
";".join(filters),
"-map",
out_label,
"-map",
f"{video_idx}:v",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:1",
]
filename = f"{_safe_title(job.title)}_video.mp4"
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> None:
"""Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3 and
FLAC are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't
meaningfully compress, and STORED keeps the build fast. Runs in a thread."""
if fmt == "wav":
with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
zf.write(p, arcname=f"{name}.wav")
return
encode = _ENCODE_ARGS[fmt]
with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
out = os.path.join(td, f"{name}.{fmt}")
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-i",
str(p),
*encode,
"-f",
fmt,
out,
]
proc = subprocess.run( # noqa: S603 — list args, no shell, trusted ffmpeg
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=TIMEOUT_FFMPEG,
)
if proc.returncode != 0:
tail = proc.stderr[-2000:].decode("utf-8", "replace")
raise RuntimeError(f"ffmpeg failed for {name}: {tail}")
zf.write(out, arcname=f"{name}.{fmt}")
@router.get("/jobs/{job_id}/stems/all.zip")
async def get_all_stems_zip(
job_id: str,
fmt: str = Query(default="wav", alias="format"),
stems: str | None = Query(default=None, description="Comma-separated stems; default all"),
) -> FileResponse:
"""Bundle the requested stems into a single ZIP, named after the song.
`stems` is the active subset selected in the DAW (whitelisted). When omitted,
every available stem is included."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
if fmt not in ("wav", "mp3", "flac"):
raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', or 'flac'")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
# Resolve the requested subset (whitelisted) or fall back to all stems.
if stems:
requested = {s for s in stems.split(",") if s}
if not requested <= set(STEM_NAMES):
raise HTTPException(status_code=422, detail="unknown stem requested")
wanted = [name for name in STEM_NAMES if name in requested]
else:
wanted = list(STEM_NAMES)
jobs_root = JOBS_DIR.resolve()
stems_dir = (JOBS_DIR / job_id / "stems").resolve()
if not stems_dir.is_dir() or not stems_dir.is_relative_to(jobs_root):
raise HTTPException(status_code=404, detail="stems not found")
sources: list[tuple[str, Path]] = []
for name in wanted:
p = (stems_dir / f"{name}.wav").resolve()
if p.is_file() and p.is_relative_to(jobs_root):
sources.append((name, p))
if not sources:
raise HTTPException(status_code=404, detail="no stems found")
fd, tmp = tempfile.mkstemp(prefix="stemdeck_zip_", suffix=".zip")
os.close(fd)
tmp_path = Path(tmp)
try:
await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path)
except Exception:
tmp_path.unlink(missing_ok=True)
logger.exception("failed to build stems zip for job %s", job_id)
raise HTTPException(status_code=500, detail="failed to build archive") from None
filename = f"{_safe_title(job.title)}_stems.zip"
return FileResponse(
tmp_path,
media_type="application/zip",
filename=filename,
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
)
View File
+121
View File
@@ -0,0 +1,121 @@
import os
import re
import sys
from pathlib import Path
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
try:
return int(raw) if raw else default
except ValueError:
return default
def _env_path(name: str, default: Path) -> Path:
raw = os.environ.get(name, "").strip()
return Path(raw).expanduser().resolve() if raw else default
def _detect_device() -> str:
"""Pick best available Torch device for Demucs. Override via
STEMDECK_DEMUCS_DEVICE env var ('cuda' | 'mps' | 'cpu'). Apple Silicon
silently falls back to CPU otherwise -- demucs's CLI default is
"cuda if available else cpu" and macOS has no CUDA, leaving the
integrated GPU idle and processing 3-5x slower than necessary."""
forced = os.environ.get("STEMDECK_DEMUCS_DEVICE", "").strip().lower()
if forced in ("cuda", "mps", "cpu"):
return forced
try:
import torch
if torch.cuda.is_available():
return "cuda"
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
return "mps"
except ImportError:
pass
return "cpu"
ROOT = Path(__file__).resolve().parent.parent.parent
STATIC_DIR = ROOT / "static"
STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other")
JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$")
# Runtime knobs -- env-backed so Docker / desktop packaging / local dev can
# tune without a code edit. STEMDECK_DATA_DIR is the portable app root for
# mutable runtime data; when unset, dev behavior remains the repo-local jobs/
# folder.
PORTABLE_DATA_DIR_ENABLED = bool(os.environ.get("STEMDECK_DATA_DIR", "").strip())
DATA_DIR = _env_path("STEMDECK_DATA_DIR", ROOT)
JOBS_DIR = _env_path(
"STEMDECK_JOBS_DIR",
(DATA_DIR / "jobs") if PORTABLE_DATA_DIR_ENABLED else (ROOT / "jobs"),
)
CACHE_DIR = _env_path("STEMDECK_CACHE_DIR", DATA_DIR / "cache")
DOWNLOADS_DIR = _env_path("STEMDECK_DOWNLOADS_DIR", DATA_DIR / "downloads")
MODELS_DIR = _env_path("STEMDECK_MODELS_DIR", DATA_DIR / "models")
LOGS_DIR = _env_path("STEMDECK_LOGS_DIR", DATA_DIR / "logs")
FFMPEG_DIR = _env_path("STEMDECK_FFMPEG_DIR", DATA_DIR / "ffmpeg")
FFMPEG_BIN = _env_path(
"STEMDECK_FFMPEG",
FFMPEG_DIR / ("ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"),
)
FFPROBE_BIN = _env_path(
"STEMDECK_FFPROBE",
FFMPEG_DIR / ("ffprobe.exe" if sys.platform.startswith("win") else "ffprobe"),
)
DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s"
DEMUCS_DEVICE = _detect_device()
MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default
JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default
MAX_PENDING_JOBS = max(1, min(50, _env_int("STEMDECK_MAX_PENDING_JOBS", 3)))
TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300)
TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120)
TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800)
# Max height for the MP4 video stream pulled from YouTube (issue #219).
# Capped to keep downloads reasonable; 1080p of a full song is large.
VIDEO_MAX_HEIGHT = max(144, _env_int("STEMDECK_VIDEO_MAX_HEIGHT", 720))
def ffmpeg_executable() -> str:
"""Return the preferred FFmpeg executable.
In portable mode, setup places FFmpeg under DATA_DIR/ffmpeg. Prefer that
binary when present; otherwise fall back to PATH so local dev and Docker
keep working exactly as before.
"""
return str(FFMPEG_BIN) if FFMPEG_BIN.is_file() else "ffmpeg"
def ffprobe_executable() -> str:
"""Return the preferred ffprobe executable (same bundled dir as ffmpeg)."""
return str(FFPROBE_BIN) if FFPROBE_BIN.is_file() else "ffprobe"
def configure_portable_environment() -> None:
"""Keep generated caches inside the portable data folder when requested.
This is intentionally best-effort. It only sets variables that are still
unset, so explicit caller/env choices win.
"""
if FFMPEG_DIR.is_dir():
path = os.environ.get("PATH", "")
ffmpeg_path = str(FFMPEG_DIR)
if ffmpeg_path not in path.split(os.pathsep):
os.environ["PATH"] = ffmpeg_path + (os.pathsep + path if path else "")
if PORTABLE_DATA_DIR_ENABLED:
os.environ.setdefault("XDG_CACHE_HOME", str(CACHE_DIR))
os.environ.setdefault("TORCH_HOME", str(MODELS_DIR / "torch"))
def ensure_runtime_dirs() -> None:
paths = (
(JOBS_DIR, CACHE_DIR, DOWNLOADS_DIR, MODELS_DIR, LOGS_DIR)
if PORTABLE_DATA_DIR_ENABLED
else (JOBS_DIR,)
)
for path in paths:
path.mkdir(parents=True, exist_ok=True)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import dataclasses
import time
from dataclasses import dataclass, field
from typing import Any, Literal
class JobCancelled(Exception):
"""Raised inside a pipeline stage when the job's cancel flag is set."""
JobStatus = Literal[
"queued", "downloading", "analyzing", "separating", "processing", "done", "error", "cancelled"
]
def _set(job: Job, **fields: object) -> None:
"""Mutate Job fields. SSE polling picks up the change automatically."""
for k, v in fields.items():
if k == "stage":
job.stage_message = v # type: ignore[assignment]
else:
setattr(job, k, v)
@dataclass
class Job:
id: str
status: JobStatus = "queued"
progress: float = 0.0
stage_message: str = "Queued"
title: str | None = None
duration_sec: float | None = None
thumbnail: str | None = None
bpm: int | None = None
key: str | None = None
scale: str | None = None # "Major" / "Natural Minor"
key_confidence: int | None = None # 0-100 percent
lufs: float | None = None # ITU-R BS.1770 integrated loudness (dB)
peak_db: float | None = None # sample peak in dBFS (close to true peak)
dynamic_range: float | None = None # peak_db - integrated LUFS (dB)
tempo_stability: int | None = None # 0-100, beat interval consistency
stem_presence: dict[str, int] | None = None # per-stem RMS 0-100
sections: list[dict] | None = None # [{id, name, start, end, color}]
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
# Subset of stems the user chose at submit. The pipeline produces all
# 6 regardless (Demucs htdemucs_6s is fixed), but after collect we
# mix down only the selected ones into mix.wav so the user can
# download a single track containing just their chosen stems.
selected_stems: list[str] = field(default_factory=list)
mix_url: str | None = None # populated when a strict subset was selected
source_url: str | None = None # original URL or "local:<filename>" for file uploads
# True when a silent video track (video.mp4) was preserved from an .mp4
# upload, enabling the "Export Mix (with video)" MP4 export.
has_video: bool = False
error: str | None = None
# Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages.
# Not surfaced via to_state() -- it's internal control state.
cancel_requested: bool = False
# Wall-clock timestamps for metadata-based sweep -- more predictable
# than directory mtime, which can be touched by unrelated FS events.
created_at: float = field(default_factory=time.time)
def to_state(self) -> dict[str, Any]:
return {
"job_id": self.id,
"status": self.status,
"progress": self.progress,
"stage": self.stage_message,
"title": self.title,
"duration": self.duration_sec,
"thumbnail": self.thumbnail,
"bpm": self.bpm,
"key": self.key,
"scale": self.scale,
"key_confidence": self.key_confidence,
"lufs": self.lufs,
"peak_db": self.peak_db,
"dynamic_range": self.dynamic_range,
"tempo_stability": self.tempo_stability,
"stem_presence": self.stem_presence,
"sections": self.sections,
"tags": self.tags,
"stems": self.stems,
"selected_stems": self.selected_stems,
"mix_url": self.mix_url,
"source_url": self.source_url,
"has_video": self.has_video,
"error": self.error,
"created_at": self.created_at,
}
def to_record(self) -> dict[str, Any]:
return {field: getattr(self, field) for field in _JOB_FIELDS}
@classmethod
def from_record(cls, data: dict[str, Any]) -> Job:
fields = {key: value for key, value in data.items() if key in _JOB_FIELDS}
job_id = str(fields.pop("id", "")).strip()
if not job_id:
raise ValueError("job record missing id")
job = cls(id=job_id)
for key, value in fields.items():
setattr(job, key, value)
job.cancel_requested = False
return job
_JOB_FIELDS = frozenset(f.name for f in dataclasses.fields(Job) if f.name != "cancel_requested")
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import logging
import subprocess
import threading
from pathlib import Path
from app.core.config import JOB_ID_RE, STEM_NAMES
from app.core.models import Job
logger = logging.getLogger("stemdeck.registry")
REGISTRY_VERSION = 1
_jobs: dict[str, Job] = {}
# Active subprocesses keyed by job_id (currently only Demucs). Lets
# POST /cancel terminate the running process from the API thread instead
# of waiting for the pipeline thread to notice the cancel flag.
_procs: dict[str, subprocess.Popen] = {}
_lock = threading.Lock()
_REGISTRY_FILE = "registry.json"
_TERMINAL = {"done"}
def register(job: Job) -> Job:
with _lock:
_jobs[job.id] = job
return job
def register_if_capacity(job: Job, max_pending: int) -> bool:
"""Atomically check pending count and register if under capacity.
Returns True if registered, False if the queue is full."""
with _lock:
pending = sum(1 for j in _jobs.values() if j.status == "queued")
if pending >= max_pending:
return False
_jobs[job.id] = job
return True
def get(job_id: str) -> Job | None:
with _lock:
return _jobs.get(job_id)
def remove(job_id: str) -> None:
with _lock:
_jobs.pop(job_id, None)
_procs.pop(job_id, None)
def all_jobs() -> dict[str, Job]:
"""Return a snapshot of the registry for sweep / cleanup."""
with _lock:
return dict(_jobs)
def _migrate(data: dict) -> dict:
"""Upgrade registry JSON to REGISTRY_VERSION incrementally.
Each block transforms v(n) → v(n+1) so older snapshots always catch up."""
version = data.get("version", 0)
if version < 1:
# v0 → v1: version field was absent; no structural change needed.
data["version"] = 1
version = 1
# Future migrations go here as `if version < N:` blocks.
return data
def persist(jobs_dir: Path) -> None:
"""Persist terminal jobs so completed library entries survive restarts."""
try:
jobs_dir.mkdir(parents=True, exist_ok=True)
except OSError:
logger.warning("cannot create jobs dir %s; skipping persist", jobs_dir, exc_info=True)
return
with _lock:
records = [
job.to_record()
for job in sorted(_jobs.values(), key=lambda item: item.created_at)
if job.status in _TERMINAL
]
payload = json.dumps({"version": REGISTRY_VERSION, "jobs": records}, indent=2) + "\n"
path = jobs_dir / _REGISTRY_FILE
tmp = path.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(path)
def restore(jobs_dir: Path) -> None:
"""Load persisted jobs and recover completed orphan jobs from disk."""
jobs_dir.mkdir(parents=True, exist_ok=True)
path = jobs_dir / _REGISTRY_FILE
if path.is_file():
try:
data = _migrate(json.loads(path.read_text(encoding="utf-8")))
to_add = {}
for record in data.get("jobs", []):
job = Job.from_record(record)
if JOB_ID_RE.match(job.id) and job.status in _TERMINAL and job.title:
to_add[job.id] = job
with _lock:
_jobs.update(to_add)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
logger.warning("failed to load registry from %s", path, exc_info=True)
with _lock:
known = set(_jobs)
changed = False
for job_dir in jobs_dir.iterdir():
if not job_dir.is_dir() or not JOB_ID_RE.match(job_dir.name) or job_dir.name in known:
continue
recovered = _recover_done_job(job_dir)
if recovered is not None:
with _lock:
_jobs[recovered.id] = recovered
changed = True
if changed:
persist(jobs_dir)
def _recover_done_job(job_dir: Path) -> Job | None:
stems_dir = job_dir / "stems"
if not stems_dir.is_dir():
return None
stems = [
{"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"}
for name in ("original", *STEM_NAMES)
if (stems_dir / f"{name}.wav").is_file()
]
if not stems:
return None
mix_url = None
if (stems_dir / "mix.wav").is_file():
mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav"
selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES)
meta_path = job_dir / "metadata.json"
if not meta_path.is_file():
return None
meta: dict = {}
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return Job(
id=job_dir.name,
status="done",
progress=1.0,
stage_message="Done",
stems=stems,
selected_stems=selected,
mix_url=mix_url,
created_at=job_dir.stat().st_mtime,
title=meta.get("title"),
thumbnail=meta.get("thumbnail"),
duration_sec=meta.get("duration_sec"),
bpm=meta.get("bpm"),
key=meta.get("key"),
scale=meta.get("scale"),
key_confidence=meta.get("key_confidence"),
lufs=meta.get("lufs"),
peak_db=meta.get("peak_db"),
dynamic_range=meta.get("dynamic_range"),
tempo_stability=meta.get("tempo_stability"),
stem_presence=meta.get("stem_presence"),
sections=meta.get("sections"),
tags=meta.get("tags"),
)
def set_proc(job_id: str, proc: subprocess.Popen | None) -> None:
with _lock:
if proc is None:
_procs.pop(job_id, None)
else:
_procs[job_id] = proc
def get_proc(job_id: str) -> subprocess.Popen | None:
with _lock:
return _procs.get(job_id)
+140
View File
@@ -0,0 +1,140 @@
"""Runtime, user-toggleable settings (persisted to disk).
These are read live (unlike the env-var constants in config.py, which are fixed
at startup), so the Settings UI can change them without a restart:
- `allow_network` — whether StemDeck answers requests from other devices.
- `max_duration_sec` — longest track accepted for processing.
- `video_max_height` — max video resolution for MP4 export / YouTube pulls.
Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
"""
from __future__ import annotations
import json
import logging
import os
import threading
from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT
_log = logging.getLogger("stemdeck.settings")
_SETTINGS_PATH = DATA_DIR / "settings.json"
_LOCK = threading.RLock()
_state: dict | None = None # whole settings dict, loaded lazily
# Clamp bounds. Max track length is capped at 20 min (the product ceiling).
_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min
_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160
_PORT_MIN, _PORT_MAX = 1024, 65535
DEFAULT_PORT = 8000
def _default_allow_network() -> bool:
# STEMDECK_ALLOW_NETWORK takes precedence when set explicitly.
# Otherwise: desktop keeps network off (user opts in via UI toggle);
# server/Docker deployments open it by default since network access is
# the entire point of a headless deployment.
env = os.environ.get("STEMDECK_ALLOW_NETWORK")
if env is not None:
return env.strip() == "1"
return os.environ.get("STEMDECK_DESKTOP") != "1"
def _load() -> dict:
try:
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except FileNotFoundError:
pass # no settings file yet — first run; use defaults
except Exception:
# Corrupt/unreadable file: fall back to defaults rather than crash.
_log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True)
return {}
def _ensure() -> dict:
global _state
if _state is None:
_state = _load()
return _state
def _save() -> None:
try:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8")
except Exception:
# Persistence is best-effort (read-only FS, permissions): the in-memory
# value still applies for this session, so don't fail the request.
_log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True)
def _num(v: object) -> int | None:
return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
# ── allow_network ──
def get_allow_network() -> bool:
with _LOCK:
v = _ensure().get("allow_network")
return v if isinstance(v, bool) else _default_allow_network()
def set_allow_network(value: bool) -> bool:
with _LOCK:
_ensure()["allow_network"] = bool(value)
_save()
return bool(value)
# ── max_duration_sec ──
def get_max_duration_sec() -> int:
with _LOCK:
v = _num(_ensure().get("max_duration_sec"))
return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC
def set_max_duration_sec(value: int) -> int:
with _LOCK:
clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value)))
_ensure()["max_duration_sec"] = clamped
_save()
return clamped
# ── video_max_height ──
def get_video_max_height() -> int:
with _LOCK:
v = _num(_ensure().get("video_max_height"))
return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT
def set_video_max_height(value: int) -> int:
with _LOCK:
clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value)))
_ensure()["video_max_height"] = clamped
_save()
return clamped
# ── port ──
# The preferred port the server binds on launch. The desktop launcher reads this
# (default 8000) before spawning the backend; a self-hosted server's --port wins.
# Changing it needs a restart — the socket is bound at startup.
def get_port() -> int:
with _LOCK:
v = _num(_ensure().get("port"))
return max(_PORT_MIN, min(_PORT_MAX, v)) if v is not None else DEFAULT_PORT
def set_port(value: int) -> int:
with _LOCK:
clamped = max(_PORT_MIN, min(_PORT_MAX, int(value)))
_ensure()["port"] = clamped
_save()
return clamped
+376
View File
@@ -0,0 +1,376 @@
from __future__ import annotations
import asyncio
import ctypes
import functools
import logging
import os
import re
import signal
import socket
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from app.api.router import router
from app.core.config import (
DEMUCS_DEVICE,
DEMUCS_MODEL,
FFMPEG_BIN,
JOBS_DIR,
STATIC_DIR,
configure_portable_environment,
ensure_runtime_dirs,
)
from app.core.registry import restore as restore_registry
from app.core.settings import (
get_allow_network,
get_max_duration_sec,
get_port,
get_video_max_height,
set_allow_network,
set_max_duration_sec,
set_port,
set_video_max_height,
)
from app.pipeline.collect import sweep_old_jobs
# Show our INFO-level logs through uvicorn's root handler. Without this,
# Python's default root level (WARNING) silently drops every
# logger.info(...) call across the app, including the analyze
# diagnostics ("chroma:", "key candidates:").
logging.getLogger("stemdeck").setLevel(logging.INFO)
logging.getLogger("stemdeck").info("demucs config: model=%s device=%s", DEMUCS_MODEL, DEMUCS_DEVICE)
configure_portable_environment()
# Pre-import librosa so the first job submission doesn't pay the 1-2 s
# cost of numpy/scipy/numba lazy initialization. Adds ~1 s to server
# boot in exchange for snappier first-job UX. Best-effort: if librosa
# isn't installed, analyze() degrades gracefully on its own.
try:
import librosa # noqa: F401 -- intentional warm-up import
except ImportError:
pass
_log = logging.getLogger("stemdeck")
def _process_exists(pid: int) -> bool:
if os.name != "nt":
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
ERROR_INVALID_PARAMETER = 87
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if handle:
kernel32.CloseHandle(handle)
return True
return ctypes.get_last_error() != ERROR_INVALID_PARAMETER
def app_version() -> str:
# Version is git-tag-derived via hatch-vcs (#169). Prefer installed package
# metadata (set at install/build from the tag); fall back to the generated
# app/_version.py for non-installed runs, then a dev placeholder.
try:
return package_version("stemdeck")
except PackageNotFoundError:
pass
try:
from app._version import __version__
return str(__version__)
except Exception:
return "0.0.0-dev"
def _sweep_disabled() -> bool:
"""The desktop app is a personal, user-curated library (folders + Trash),
with its track list persisted permanently in ~/Documents/StemDeck. The 24h
job TTL sweep -- a sensible disk-hygiene default for the shared server/Docker
deployment -- would wrongly purge stems the user kept, leaving orphaned
library entries that ask to "re-upload to restore".
So skip the sweep under the desktop shell (STEMDECK_DESKTOP=1), or when a
self-hosted deployment opts into a persistent library
(STEMDECK_PERSIST_LIBRARY=1 -- set by default in run.sh). The user manages
disk via Trash. Shared/Docker deployments that set neither keep the sweep."""
return (
os.environ.get("STEMDECK_DESKTOP") == "1"
or os.environ.get("STEMDECK_PERSIST_LIBRARY") == "1"
)
async def _sweep_loop() -> None:
if _sweep_disabled():
_log.info("job TTL sweep disabled (persistent library; user-managed)")
return
while True:
try:
await asyncio.to_thread(sweep_old_jobs, JOBS_DIR)
except Exception:
_log.warning("sweep failed", exc_info=True)
await asyncio.sleep(3600)
async def _desktop_parent_watchdog(parent_pid: int) -> None:
while True:
if not _process_exists(parent_pid):
_log.info("desktop parent process exited; stopping backend")
# Send SIGTERM to ourselves so uvicorn runs its shutdown sequence
# instead of bypassing cleanup with os._exit().
os.kill(os.getpid(), signal.SIGTERM)
return
await asyncio.sleep(1)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
_background_tasks = set()
t = asyncio.create_task(_sweep_loop())
_background_tasks.add(t)
t.add_done_callback(_background_tasks.discard)
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():
wt = asyncio.create_task(_desktop_parent_watchdog(parent_pid_int))
_background_tasks.add(wt)
wt.add_done_callback(_background_tasks.discard)
yield
# Phones hitting the self-hosted server URL get the mobile UI; everything
# else (desktop browsers, and the Tauri webviews, which all report desktop
# user-agents) gets the DAW. Tablets are intentionally treated as desktop —
# the DAW layout is usable there. "Mobi" is the cross-browser marker for a
# phone form factor (Chrome/Firefox/Safari all include it); the rest cover
# vendors that don't.
_MOBILE_UA_RE = re.compile(
r"Mobi|Android|iPhone|iPod|IEMobile|BlackBerry|Opera Mini", re.IGNORECASE
)
def _is_mobile_ua(user_agent: str) -> bool:
return bool(user_agent) and _MOBILE_UA_RE.search(user_agent) is not None
app = FastAPI(
title="StemDeck",
description="Paste a YouTube URL or upload an audio file, get audio stems split into a DAW-style player.",
version=app_version(),
lifespan=lifespan,
)
@app.get("/", include_in_schema=False)
def index(request: Request) -> FileResponse:
"""Serve the mobile shell to phones, the DAW to everyone else. `?ui=mobile`
/ `?ui=desktop` forces either one (handy for testing from a desktop). This
route is registered before the StaticFiles mount at "/", so it wins for the
bare path while the mount still serves every other asset."""
ui = request.query_params.get("ui")
if ui == "mobile":
mobile = True
elif ui == "desktop":
mobile = False
else:
mobile = _is_mobile_ua(request.headers.get("user-agent", ""))
page = "mobile/index.html" if mobile else "index.html"
return FileResponse(STATIC_DIR / page)
@app.get("/health", include_in_schema=False)
def health_root() -> dict[str, object]:
return health()
@app.get("/api/health", tags=["health"])
def health() -> dict[str, object]:
return {
"name": "StemDeck",
"status": "ok",
"version": app_version(),
"ffmpeg_configured": FFMPEG_BIN.is_file(),
"demucs_model": DEMUCS_MODEL,
"demucs_device": DEMUCS_DEVICE,
}
def _is_lan_ipv4(ip: str) -> bool:
"""A reachable IPv4 LAN address to show another device: not IPv6 (link-local
needs a zone index and won't work in a browser), not loopback, not the
169.254.x auto-config range."""
if ":" in ip: # IPv6
return False
if _is_loopback(ip) or ip.startswith("169.254."):
return False
parts = ip.split(".")
return len(parts) == 4 and all(p.isdigit() for p in parts)
def _settings_payload() -> dict[str, object]:
return {
"allow_network": get_allow_network(),
"max_duration_sec": get_max_duration_sec(),
"video_max_height": get_video_max_height(),
"port": get_port(),
}
@app.get("/api/settings", tags=["settings"])
def get_settings(request: Request) -> dict[str, object]:
# LAN addresses other devices can use — loopback excluded (only works on the
# host). The port is whatever this request came in on.
port = request.url.port or 8000
addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip))
return {**_settings_payload(), "lan_addresses": addresses}
@app.post("/api/settings", tags=["settings"])
async def update_settings(request: Request) -> dict[str, object]:
"""Update runtime settings. Reachable from the host machine always; from a
LAN device only while network access is currently on (the gate below), so a
phone can't change settings once the owner turned access off."""
try:
body = await request.json()
except Exception:
body = {}
if "allow_network" in body:
set_allow_network(bool(body["allow_network"]))
for key, setter in (
("max_duration_sec", set_max_duration_sec),
("video_max_height", set_video_max_height),
("port", set_port),
):
if key in body:
try:
setter(int(body[key]))
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None
return _settings_payload()
# Content-Security-Policy. Defense-in-depth so an injected string in the webview
# can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171.
# script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the
# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles*
# only (the UI sets many style attributes). Allowances:
# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC
# img-src https: -> remote YouTube/SoundCloud thumbnails
# style/font -> the Google Fonts <link>
_CSP = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com data:; "
"img-src 'self' data: blob: https:; "
"media-src 'self' blob: data:; "
# data:/blob: are required by multitrack.js (it fetches a data: URI during
# track init); without them Multitrack.create throws and no audio loads
# (#186). They are inline/same-origin schemes, not network endpoints, so
# they add no exfiltration channel — script-src below stays locked.
"connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob:; "
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
)
# Force browsers to revalidate static assets on every request. Without
# this the JS/CSS modules can stick in disk cache across server
# restarts -- updated HTML loads against stale modules and the form
# silently breaks. `must-revalidate` keeps 304s working (cheap) while
# guaranteeing the latest mtime is honored.
@app.middleware("http")
async def security_and_cache_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = _CSP
if not request.url.path.startswith("/api"):
response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response
def _is_loopback(host: str | None) -> bool:
if not host:
return False
if host.startswith("::ffff:"): # IPv4-mapped IPv6
host = host[7:]
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
@functools.lru_cache(maxsize=1)
def _local_ips() -> frozenset[str]:
"""The machine's own interface IPs. Used so the host always reaches the app
even via its LAN address (e.g. 192.168.x.x), not just 127.0.0.1 — turning
network access off must never cut the host off from its own server."""
ips: set[str] = set()
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None):
ips.add(info[4][0])
except Exception:
# Best-effort: name resolution can fail on odd hostnames/configs; we
# still try the outbound-socket probe below and fall back to loopback.
_log.debug("hostname IP enumeration failed", exc_info=True)
try: # primary outbound IP, robust when the hostname doesn't resolve them all
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ips.add(s.getsockname()[0])
s.close()
except Exception:
# Best-effort: no default route / offline — just return what we have.
_log.debug("outbound IP probe failed", exc_info=True)
return frozenset(ips)
def _is_host_request(host: str | None) -> bool:
"""True when the request originates from the machine StemDeck runs on —
whether via loopback or one of its own interface addresses."""
if _is_loopback(host):
return True
if not host:
return False
h = host[7:] if host.startswith("::ffff:") else host
return h in _local_ips()
# Network availability gate (Settings → "Make StemDeck available on your
# network"). Added after the headers middleware so it is the OUTERMOST layer and
# short-circuits before anything else. It NEVER stops the server — it only
# refuses requests from OTHER devices when availability is off. The host machine
# (loopback or its own LAN IP) is always served, so the app keeps working
# locally regardless of this setting.
@app.middleware("http")
async def network_gate(request: Request, call_next):
if not get_allow_network():
client_host = request.client.host if request.client else None
if not _is_host_request(client_host):
return PlainTextResponse(
"StemDeck is not available on the network. Enable it in Settings on the host machine.",
status_code=403,
)
return await call_next(request)
app.include_router(router, prefix="/api")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
ensure_runtime_dirs()
restore_registry(JOBS_DIR)
+3
View File
@@ -0,0 +1,3 @@
from app.pipeline.runner import run_local_pipeline, run_pipeline
__all__ = ["run_pipeline", "run_local_pipeline"]
+331
View File
@@ -0,0 +1,331 @@
from __future__ import annotations
import logging
import subprocess
from pathlib import Path
from app.core.config import JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable
from app.core.models import Job, _set
logger = logging.getLogger("stemdeck.analyze")
# Albrecht-Shanahan key profiles, derived from a corpus of popular music
# (Albrecht & Shanahan, 2013). Critically, the minor profile here weights
# b7 high (3.48) and M7 low (0.81) — the opposite of Temperley/Kostka-Payne,
# which were derived from Bach chorales and bias toward harmonic minor's
# leading tone. Pop/rock uses natural minor: the b7 is the diatonic
# seventh and rings out constantly (e.g. open D in "Come As You Are",
# which is in E minor and uses D as the b7). Values rescaled so that the
# tonic weight is ≈5 to match the prior code's magnitude.
_MAJOR_PROFILE = (
5.47,
0.14,
2.55,
0.14,
3.15,
2.16,
0.37,
4.92,
0.21,
1.84,
0.18,
1.86,
)
_MINOR_PROFILE = (
5.06,
0.14,
2.42,
2.42,
0.35,
1.96,
0.35,
4.16,
2.53,
0.28,
2.67,
0.62,
)
_PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
# When the best-major and best-minor scores are this close, we prefer
# minor. Pop/rock has a strong minor-mode prior; the algorithm often
# walks toward the relative major because of an ostinato bass note
# (e.g. "Come As You Are" hammers the open D string in an E minor song),
# and minor is the better default when the call is genuinely ambiguous.
_MINOR_TIE_BREAK_FRAC = 0.05
def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float:
n = len(profile)
rotated = [chroma[(i + shift) % n] for i in range(n)]
mean_p = sum(profile) / n
mean_c = sum(rotated) / n
num = sum((profile[i] - mean_p) * (rotated[i] - mean_c) for i in range(n))
denom_p = sum((profile[i] - mean_p) ** 2 for i in range(n)) ** 0.5
denom_c = sum((rotated[i] - mean_c) ** 2 for i in range(n)) ** 0.5
if denom_p == 0 or denom_c == 0:
return 0.0
return num / (denom_p * denom_c)
def _detect_key(chroma_mean: list[float]) -> tuple[str, str, int]:
"""Find the best-matching key by combining profile correlation with
root prominence. The Pearson correlation alone is fooled by relative
keys whose diatonic notes happen to overlap with the song's loud
pitches but whose own tonic is weak (e.g. picking A minor for an
E-minor song because E is its 5th and D is its 4th). Weighting by
the candidate root's chroma value forces the algorithm to also
confirm 'is this proposed tonic actually loud in the audio?'.
Logs the chroma vector and top-5 candidates for diagnostics.
Returns (label, scale_name, confidence_pct).
- label: e.g. "G# maj"
- scale_name: "Major" or "Natural Minor"
- confidence_pct: 0-100, derived from the gap between the winning
candidate and the runner-up, normalized so a clear
win ranks high and a near-tie ranks low."""
raw: list[tuple[float, float, str, int]] = [] # (weighted, pearson, label, root_idx)
for shift in range(12):
root_strength = chroma_mean[shift]
pearson_maj = _correlate(_MAJOR_PROFILE, chroma_mean, shift)
pearson_min = _correlate(_MINOR_PROFILE, chroma_mean, shift)
# Multiplicative root weighting. Pearson can be negative; when
# it is, a low-chroma root makes things less negative (closer to
# zero), which is actually the desired ordering.
raw.append((pearson_maj * root_strength, pearson_maj, f"{_PITCHES[shift]} maj", shift))
raw.append((pearson_min * root_strength, pearson_min, f"{_PITCHES[shift]} min", shift))
raw.sort(key=lambda x: x[0], reverse=True)
# Diagnostic log: chroma profile + top 5 candidates with both raw
# and weighted scores. Lets us see what the algorithm is "hearing".
chroma_str = ", ".join(f"{_PITCHES[i]}={chroma_mean[i]:.3f}" for i in range(12))
top5_str = ", ".join(
f"{label}={weighted:+.3f}(p{pearson:+.2f}*r{chroma_mean[idx]:.2f})"
for weighted, pearson, label, idx in raw[:5]
)
logger.debug("chroma: %s", chroma_str)
logger.debug("key candidates (top 5): %s", top5_str)
# Pick best major and best minor for the tie-break, both by the
# weighted score.
best_maj = next(c for c in raw if c[2].endswith("maj"))
best_min = next(c for c in raw if c[2].endswith("min"))
gap = abs(best_maj[0] - best_min[0])
threshold = max(abs(best_maj[0]), abs(best_min[0])) * _MINOR_TIE_BREAK_FRAC
# Near-tie -> prefer minor (pop/rock prior); clear winner -> use it.
winner = (best_maj if best_maj[0] > best_min[0] else best_min) if gap > threshold else best_min
# Confidence: gap between the winner and the runner-up that *isn't*
# the relative major/minor of the winner (those will always be near-
# ties with the algorithm's profile-correlation approach, so they
# tell us nothing about real ambiguity). Normalize so a healthy 0.15
# gap = 100% confident; tiny gap = 0%.
runner_up = next(c for c in raw if c[2] != winner[2])
confidence_score = winner[0] - runner_up[0]
confidence_pct = max(0, min(100, round(confidence_score / 0.15 * 100)))
label = winner[2]
scale_name = "Major" if label.endswith("maj") else "Natural Minor"
return label, scale_name, confidence_pct
def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]:
"""Compute integrated loudness (LUFS, BS.1770) and sample peak (dBFS)
of the loaded mono signal. Returns (lufs, peak_db); either may be
None on failure or silence. We use sample peak rather than oversampled
true peak -- the difference is typically <1 dB and not worth the 4x
resample cost for a display field."""
import numpy as np
if y is None or getattr(y, "size", 0) == 0:
return None, None
peak_lin = float(np.abs(y).max())
peak_db = 20.0 * float(np.log10(peak_lin)) if peak_lin > 1e-9 else None
lufs: float | None = None
try:
import pyloudnorm as pyln
meter = pyln.Meter(sr) # BS.1770-4 with default 400ms blocks
lufs_raw = float(meter.integrated_loudness(y))
# pyloudnorm returns -inf for silence; surface as None instead so
# the frontend can hide the field rather than render "-inf LUFS".
if np.isfinite(lufs_raw):
lufs = lufs_raw
except (ImportError, ValueError) as e:
# ValueError fires if the clip is shorter than the gating window.
logger.warning("LUFS measurement failed: %s", e)
return lufs, peak_db
def _load_audio_ffmpeg(
source: Path, sr: int = 22050, duration: float = 180.0
) -> tuple[object, int] | None:
"""Decode `source` to a mono float32 numpy array at `sr` via ffmpeg.
Bypasses librosa's deprecated audioread fallback (which fires a
FutureWarning on .webm/.m4a/.opus inputs because soundfile can't
read those directly). Returns (samples, sr) or None on failure."""
import numpy as np
# Defence in depth: even though `source` is constructed by the server
# (never user-typed), confirm it's a real file inside JOBS_DIR before
# handing it to a subprocess. Belt-and-suspenders against a future
# caller change that would let a path slip in from elsewhere.
resolved = source.resolve()
jobs_resolved = JOBS_DIR.resolve()
if not resolved.is_file():
logger.warning("analyze source is not a file: %s", source)
return None
if not resolved.is_relative_to(jobs_resolved):
logger.warning(
"analyze source escapes JOBS_DIR (%s not under %s)",
resolved,
jobs_resolved,
)
return None
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-i",
str(resolved),
"-ac",
"1", # mono
"-ar",
str(sr), # resample
"-f",
"f32le", # raw 32-bit float little-endian
"-t",
str(duration), # cap input duration
"-", # write to stdout
]
try:
proc = subprocess.run(cmd, capture_output=True, check=True, timeout=TIMEOUT_ANALYZE)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.warning("ffmpeg decode failed for %s: %s", source, e)
return None
y = np.frombuffer(proc.stdout, dtype=np.float32)
if y.size == 0:
return None
return y, sr
def compute_stem_presence(stems_dir: Path, selected_stems: list[str]) -> dict[str, int]:
"""Load each extracted stem WAV, compute mean absolute amplitude, normalize
to 0-100. Only the stems that were selected (and therefore extracted) are
measured; the rest are omitted from the returned dict."""
import numpy as np
result: dict[str, int] = {}
rms_values: dict[str, float] = {}
for name in selected_stems:
wav_path = stems_dir / f"{name}.wav"
if not wav_path.is_file():
continue
loaded = _load_audio_ffmpeg(wav_path, sr=22050, duration=180.0)
if loaded is None:
continue
y, _ = loaded
rms_values[name] = float(np.sqrt(np.mean(y**2)))
if not rms_values:
return result
max_rms = max(rms_values.values())
if max_rms < 1e-9:
return {name: 0 for name in rms_values}
for name, rms in rms_values.items():
result[name] = max(0, min(100, round(rms / max_rms * 100)))
return result
def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
"""Best-effort BPM and key detection. On failure, returns (None, None)
and leaves job fields untouched -- the chips stay as placeholders."""
logger.info("analyze: entering for job %s, source=%s", job.id, source)
_set(job, status="analyzing", progress=0.0, stage="Analyzing audio...")
try:
import librosa
except ImportError:
logger.warning("librosa not installed -- skipping BPM/key analysis")
return None, None
try:
# Analyse the first 180 s. Decode via ffmpeg directly into numpy
# to avoid librosa's deprecated audioread fallback for
# .webm/.m4a/.opus inputs.
loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0)
if loaded is None:
return None, None
y, sr = loaded
# Harmonic / percussive separation. Beat tracking sees a cleaner
# onset envelope on the percussive component; chroma sees a
# cleaner pitch profile on the harmonic component (no cymbal
# smear, no kick fundamentals leaking in).
y_harmonic, y_percussive = librosa.effects.hpss(y)
tempo_arr, beat_frames = librosa.beat.beat_track(y=y_percussive, sr=sr)
try:
tempo = float(tempo_arr[0]) # type: ignore[index]
except (TypeError, IndexError):
tempo = float(tempo_arr)
bpm = int(round(tempo)) if tempo > 0 else None
# chroma_cqt is constant-Q based — better pitch resolution than
# chroma_stft, especially in the bass register where the open
# strings of a guitar live.
chroma = librosa.feature.chroma_cqt(y=y_harmonic, sr=sr)
chroma_mean = chroma.mean(axis=1).tolist()
if any(chroma_mean):
key, scale, key_confidence = _detect_key(chroma_mean)
else:
key, scale, key_confidence = None, None, None
# LUFS / peak. Computed on the same 22 kHz mono buffer; this
# loses a few dB of accuracy vs full-sample-rate stereo, but
# it's good enough for a UI display and adds ~50 ms to analyze.
lufs, peak_db = _measure_loudness(y, sr)
dynamic_range: float | None = None
if lufs is not None and peak_db is not None:
dynamic_range = round(peak_db - lufs, 1)
# Beat interval coefficient of variation → stability 0-100.
# CV = std/mean of inter-beat intervals; CV=0 is perfectly metronomic.
tempo_stability: int | None = None
import numpy as np
beat_times = librosa.frames_to_time(beat_frames, sr=sr)
if len(beat_times) > 2:
intervals = np.diff(beat_times)
mean_iv = float(intervals.mean())
if mean_iv > 0:
cv = float(intervals.std() / mean_iv)
tempo_stability = max(0, min(100, round((1 - min(cv, 1)) * 100)))
_set(
job,
bpm=bpm,
key=key,
scale=scale,
key_confidence=key_confidence,
lufs=lufs,
peak_db=peak_db,
dynamic_range=dynamic_range,
tempo_stability=tempo_stability,
progress=1.0,
stage="Analysis complete",
)
return bpm, key
except Exception as e:
logger.exception("analyze failed for job %s", job.id)
_set(job, stage=f"Analysis skipped ({e})")
return None, None
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import json
import logging
import shutil
import subprocess
import time
from pathlib import Path
import numpy as np
import soundfile as sf
from app.core.config import (
DEMUCS_MODEL,
JOB_TTL_SECONDS,
STEM_NAMES,
TIMEOUT_FFMPEG,
ffmpeg_executable,
)
from app.core.models import Job
from app.core.registry import all_jobs as registry_all
from app.core.registry import persist as registry_persist
from app.core.registry import remove as registry_remove
from app.core.registry import set_proc
logger = logging.getLogger("stemdeck.collect")
def _rmtree(path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
pass
except Exception:
logger.warning("failed to remove %s", path, exc_info=True)
def _run_ffmpeg(job: Job, cmd: list[str]) -> bool:
"""Run an ffmpeg command, registering the subprocess with the job
registry so POST /api/jobs/{id}/cancel can terminate it. Returns
True on success, False on failure or external termination.
Without registering the proc, an in-flight ffmpeg amix would block
cancellation for up to its 300s timeout -- the cancel flag is set
but the runner can't see it until subprocess.run returns. With
set_proc, the cancel API can call proc.terminate() directly and
communicate() returns within ~1s with a non-zero returncode."""
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
set_proc(job.id, proc)
try:
try:
_, stderr = proc.communicate(timeout=TIMEOUT_FFMPEG)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
logger.warning("ffmpeg timed out for job %s", job.id)
return False
if proc.returncode != 0:
tail = (stderr or b"").decode(errors="replace").splitlines()[-3:]
logger.warning(
"ffmpeg exit %s for job %s: %s",
proc.returncode,
job.id,
" | ".join(tail) or "(no stderr)",
)
return False
return True
finally:
set_proc(job.id, None)
_TERMINAL = frozenset(("done", "error", "cancelled"))
def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]:
"""Move Demucs-emitted stems into the job's stems/ dir and clean up
the demucs intermediate dir. Does NOT delete the source download --
cleanup_source() is called by the runner after any post-processing
that needs to re-encode the source (e.g. building original.wav)."""
target_dir = job_dir / "stems"
target_dir.mkdir(exist_ok=True)
found: list[str] = []
for name in STEM_NAMES:
src = stems_root / f"{name}.wav"
if src.exists():
shutil.move(str(src), target_dir / f"{name}.wav")
found.append(name)
_rmtree(job_dir / DEMUCS_MODEL)
if not found:
raise RuntimeError("no stems produced by demucs")
return found
def cleanup_source(job_dir: Path) -> None:
"""Delete the source audio file. Called after collect AND after any
post-processing that re-encodes the source (make_original_track).
The source is 100-300 MB, so getting rid of it is the bulk of disk
reclaim per job; only the stems remain."""
for f in job_dir.glob("source.*"):
f.unlink(missing_ok=True)
def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None:
"""Build the "Original" backing track at stems/original.wav as the
sum of the stems the user did NOT select. This way the studio can
play (original + each selected stem) and reconstruct the full song
without doubling the selected stems -- which is what would happen
if "original" were the raw source download (drum hits in original
+ isolated drums.wav = drums at 2x amplitude).
Skipped when the user kept all 6 stems (no complement to mix) or
when none of the unselected stem WAVs are on disk."""
unselected = [s for s in STEM_NAMES if s not in job.selected_stems]
inputs = [stems_dir / f"{name}.wav" for name in unselected]
inputs = [p for p in inputs if p.exists()]
if not inputs:
return None
out = stems_dir / "original.wav"
cmd: list[str] = [
ffmpeg_executable(),
"-y",
"-nostdin",
"-loglevel",
"error",
]
for p in inputs:
cmd += ["-i", str(p)]
if len(inputs) == 1:
# Single complement stem -- copy as-is so we still produce a
# canonical mix.wav-shaped output without invoking amix on a
# 1-input graph (which is a no-op anyway).
cmd += ["-c:a", "pcm_s16le", str(out)]
else:
filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs)))
cmd += [
"-filter_complex",
f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0",
"-c:a",
"pcm_s16le",
str(out),
]
return out if _run_ffmpeg(job, cmd) else None
def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | None:
"""If the user picked a strict subset of stems at submit time,
sum those stems with ffmpeg amix into mix.wav. Returns the output
path on success, or None when there's nothing to mix.
Returns the existing single stem path (no ffmpeg) if exactly one
stem was selected -- copying it to mix.wav would be 30 MB of
duplicate data. The caller uses the returned path's name for the
download URL, so a single-stem selection points the Download Mix
button directly at the existing stem file.
amix normalize=0 keeps stem amplitudes as-is. Demucs separations
sum back to (close to) the original signal, so a 2-stem subset
fits comfortably below 0 dBFS without normalization headroom."""
selected = [s for s in job.selected_stems if s in found]
if not selected:
return None
if len(selected) == 1:
return stems_dir / f"{selected[0]}.wav"
inputs = [stems_dir / f"{name}.wav" for name in selected]
out = stems_dir / "mix.wav"
cmd: list[str] = [
ffmpeg_executable(),
"-y",
"-nostdin",
"-loglevel",
"error",
]
for p in inputs:
cmd += ["-i", str(p)]
filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs)))
cmd += [
"-filter_complex",
f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0",
"-c:a",
"pcm_s16le",
str(out),
]
return out if _run_ffmpeg(job, cmd) else None
_PEAK_POINTS = 1500 # matches OVERVIEW_WAVE_POINTS in player.js
def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> None:
"""Compute and cache [min, max] waveform peaks for each stem.
Failure is non-fatal — missing peaks.json degrades to client-side decode."""
peaks: dict[str, list[list[float]]] = {}
for name in stem_names:
path = stems_dir / f"{name}.wav"
if not path.is_file():
continue
try:
data, _ = sf.read(path, dtype="float32", always_2d=True)
ch = data[:, 0]
n = len(ch)
if n == 0:
continue
chunk = max(1, n // _PEAK_POINTS)
result: list[list[float]] = []
for i in range(0, n, chunk):
block = ch[i : i + chunk]
result.append([float(np.min(block)), float(np.max(block))])
peaks[name] = result[:_PEAK_POINTS]
except Exception:
logger.warning("could not compute peaks for %s/%s", stems_dir.name, name, exc_info=True)
if not peaks:
return
try:
tmp = stems_dir / "peaks.json.tmp"
tmp.write_text(json.dumps(peaks), encoding="utf-8")
tmp.replace(stems_dir / "peaks.json")
except Exception:
logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True)
def sweep_old_jobs(jobs_dir: Path) -> None:
"""Delete job directories older than JOB_TTL_SECONDS and remove them from
the in-memory registry. Called hourly from the background sweep loop
started at app startup.
Prefers Job.created_at over directory mtime (which can be touched by
unrelated filesystem events), and never deletes the directory of an
active (non-terminal) registered job even if its timestamp looks old.
Falls back to mtime for orphan directories left over from a previous
server run, since the registry is in-memory only."""
cutoff = time.time() - JOB_TTL_SECONDS
if not jobs_dir.is_dir():
return
jobs = registry_all()
removed = False
for d in jobs_dir.iterdir():
if not d.is_dir():
continue
job = jobs.get(d.name)
if job is not None:
if job.status not in _TERMINAL:
continue # never delete an active job's working dir
if job.created_at >= cutoff:
continue
elif d.stat().st_mtime >= cutoff:
continue
_rmtree(d)
registry_remove(d.name)
removed = True
if removed:
registry_persist(jobs_dir)
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import logging
import re
import time
import urllib.parse
from pathlib import Path
from yt_dlp import YoutubeDL
from app.core.config import FFMPEG_DIR
from app.core.models import Job, JobCancelled, _set
from app.core.settings import get_max_duration_sec, get_video_max_height
logger = logging.getLogger("stemdeck.download")
_MAX_RETRIES = 3
_RETRY_BACKOFF = (2, 4, 8) # seconds between attempts
# Errors worth retrying — transient network blips.
_RETRIABLE = (
"connection reset",
"ssl",
"timed out",
"network is unreachable",
"temporary failure",
"unable to download",
"read timed out",
"remotedisconnected",
"broken pipe",
"connection refused",
)
# Errors that will never succeed on retry — reject immediately.
_NON_RETRIABLE = (
"private video",
"video unavailable",
"has been removed",
"http error 404",
"http error 403",
"not available in your country",
"age-restricted",
)
def _is_retriable(exc: Exception) -> bool:
msg = str(exc).lower()
if any(s in msg for s in _NON_RETRIABLE):
return False
return any(s in msg for s in _RETRIABLE)
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
_YOUTUBE_HOSTS = frozenset(
(
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
)
)
# Note: on.soundcloud.com (the share shortener) is intentionally excluded — it
# redirects to arbitrary targets, which is an SSRF vector once handed to yt-dlp
# (#173). Users must paste the full soundcloud.com URL.
_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com"))
_ALLOWED_HOSTS = _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS
# Restrict yt-dlp to the extractors we actually support. Crucially this excludes
# the "generic" extractor, so even a URL that slips past host validation cannot
# make yt-dlp fetch an arbitrary host/redirect target (#173).
_ALLOWED_EXTRACTORS = ["youtube", "soundcloud"]
class InvalidYouTubeURL(ValueError):
"""Raised at the API boundary for URLs we won't hand to yt-dlp."""
def validate_youtube_url(url: str) -> str:
"""Reject anything that isn't an http(s) URL on a known supported host.
YouTube URLs are normalized to single-video form; SoundCloud URLs are
passed through as-is. Gives callers a clean 422 instead of a yt-dlp
extractor stack trace."""
if not isinstance(url, str) or not url.strip():
raise InvalidYouTubeURL("URL is required")
url = url.strip()
try:
parsed = urllib.parse.urlparse(url)
except Exception as e:
raise InvalidYouTubeURL(f"could not parse URL: {e}") from e
if parsed.scheme not in ("http", "https"):
raise InvalidYouTubeURL("URL must use http or https")
host = (parsed.hostname or "").lower()
if host not in _ALLOWED_HOSTS:
raise InvalidYouTubeURL(f"unsupported host: {host or '(empty)'}")
if host in _SOUNDCLOUD_HOSTS:
return url
normalized = normalize_youtube_url(url)
# normalize_youtube_url returns the original on playlist-only URLs with
# no derivable seed video. We always expect the canonical watch?v=... form.
if not normalized.startswith("https://www.youtube.com/watch?v="):
raise InvalidYouTubeURL("could not extract a video ID from URL")
return normalized
def normalize_youtube_url(url: str) -> str:
"""Coerce a YouTube URL to a single-video form so yt-dlp doesn't end up in
the playlist extractor. Pass non-YouTube URLs through unchanged.
Cases handled:
* `watch?v=X&list=...` -> `watch?v=X` (drop the playlist context)
* `?list=RD<videoId>&start_radio=1` -> `watch?v=<videoId>` (Radio
playlists embed the seed in the list ID; YouTube refuses to view the
playlist directly with "This playlist type is unviewable.")
* `youtu.be/<videoId>` -> `watch?v=<videoId>`
* `youtube.com/shorts/<videoId>` -> `watch?v=<videoId>`
Everything else (PL/OL/algorithmic playlists with no derivable seed) is
left alone -- yt-dlp will surface its own error.
"""
try:
parsed = urllib.parse.urlparse(url)
except Exception:
return url
host = (parsed.hostname or "").lower()
for prefix in ("www.", "m.", "music."):
if host.startswith(prefix):
host = host[len(prefix) :]
break
if host not in ("youtube.com", "youtu.be"):
return url
qs = urllib.parse.parse_qs(parsed.query)
if (vid := (qs.get("v") or [None])[0]) and _VIDEO_ID_RE.match(vid):
return f"https://www.youtube.com/watch?v={vid}"
if (
(lst := (qs.get("list") or [None])[0])
and lst.startswith("RD")
and _VIDEO_ID_RE.match(lst[2:13])
):
return f"https://www.youtube.com/watch?v={lst[2:13]}"
if host == "youtu.be":
vid = parsed.path.lstrip("/")
if _VIDEO_ID_RE.match(vid):
return f"https://www.youtube.com/watch?v={vid}"
if host == "youtube.com" and parsed.path.startswith("/shorts/"):
vid = parsed.path[len("/shorts/") :].lstrip("/").split("/")[0]
if _VIDEO_ID_RE.match(vid):
return f"https://www.youtube.com/watch?v={vid}"
return url
def _download_video_track(job: Job, url: str, job_dir: Path) -> None:
"""Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the
MP4 export (issue #219). The audio source is downloaded separately as
usual; this is a second, additive fetch so the audio pipeline is untouched.
Video-only MP4 needs no ffmpeg merge, so this can't break an audio-only job:
any failure (no progressive MP4 video, network error, unsupported codec) is
logged and swallowed, leaving has_video False. A cancel mid-download raises
JobCancelled, which the runner treats like any other cancellation.
Capped at VIDEO_MAX_HEIGHT to keep downloads reasonable -- a full song at
1080p is large, and the MP4 export doesn't need it."""
def vhook(d: dict) -> None:
if job.cancel_requested:
raise JobCancelled()
if d.get("status") == "downloading":
total = d.get("total_bytes") or d.get("total_bytes_estimate")
if total:
p = float(d.get("downloaded_bytes", 0)) / float(total)
_set(job, stage=f"Fetching video {int(p * 100)}%")
# Prefer H.264 (avc1) so the exported MP4 plays everywhere -- YouTube also
# serves AV1/VP9 in mp4 containers, which many players (Safari/iOS, older
# devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists.
max_height = get_video_max_height()
ydl_opts = {
"format": (
f"bestvideo[height<={max_height}][vcodec^=avc1]"
f"/bestvideo[height<={max_height}][ext=mp4]"
),
"outtmpl": str(job_dir / "video.%(ext)s"),
"quiet": True,
"noprogress": True,
"noplaylist": True,
"allowed_extractors": _ALLOWED_EXTRACTORS,
"progress_hooks": [vhook],
}
# Point yt-dlp at the bundled ffmpeg in case a DASH stream needs remuxing;
# in portable builds ffmpeg is not on PATH.
if FFMPEG_DIR.is_dir():
ydl_opts["ffmpeg_location"] = str(FFMPEG_DIR)
_set(job, stage="Fetching video...")
try:
with YoutubeDL(ydl_opts) as ydl:
ydl.extract_info(url, download=True)
except JobCancelled:
raise
except Exception as exc:
if job.cancel_requested:
raise JobCancelled() from exc
logger.warning("[%s] video track unavailable (audio-only): %s", job.id, exc)
video = job_dir / "video.mp4"
if video.is_file() and video.stat().st_size > 0:
job.has_video = True
else:
# Drop any partial/non-mp4 leftover so the export endpoint sees nothing.
for f in job_dir.glob("video.*"):
f.unlink(missing_ok=True)
def download(job: Job, url: str, job_dir: Path) -> Path:
url = normalize_youtube_url(url)
logger.info("[%s] download starting: %s", job.id, url)
_set(job, status="downloading", progress=0.0, stage="Processing...")
# Fetch metadata first (no download) so we can reject videos that are
# too long before wasting bandwidth and disk.
with YoutubeDL(
{"quiet": True, "noplaylist": True, "allowed_extractors": _ALLOWED_EXTRACTORS}
) as ydl:
meta = ydl.extract_info(url, download=False) or {}
duration = meta.get("duration") or 0
max_duration = get_max_duration_sec()
if duration > max_duration:
mins = max_duration // 60
raise RuntimeError(f"Video is {int(duration // 60)} min -- limit is {mins} min")
def hook(d: dict) -> None:
# yt-dlp calls this on each chunk; raising here aborts the download.
# The runner unwraps yt-dlp's DownloadError and routes to JobCancelled.
if job.cancel_requested:
raise JobCancelled()
if d.get("status") == "downloading":
total = d.get("total_bytes") or d.get("total_bytes_estimate")
if total:
p = float(d.get("downloaded_bytes", 0)) / float(total)
_set(job, progress=p, stage=f"Downloading {int(p * 100)}%")
elif d.get("status") == "finished":
_set(job, progress=1.0, stage="Download complete")
# YouTube jobs additionally fetch the real video stream (below) for the
# MP4 export (issue #219). SoundCloud is audio-only and excluded.
is_youtube = url.startswith("https://www.youtube.com/")
# No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...)
# directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest
# part of the download pipeline and a lot of disk.
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": str(job_dir / "source.%(ext)s"),
"quiet": True,
"noprogress": True,
"noplaylist": True,
"allowed_extractors": _ALLOWED_EXTRACTORS,
"progress_hooks": [hook],
}
info: dict = {}
for attempt in range(_MAX_RETRIES + 1):
try:
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True) or {}
break
except Exception as exc:
if job.cancel_requested:
raise JobCancelled() from exc
if attempt < _MAX_RETRIES and _is_retriable(exc):
wait = _RETRY_BACKOFF[attempt]
logger.warning(
"[%s] download attempt %d/%d failed (%s), retrying in %ds",
job.id,
attempt + 1,
_MAX_RETRIES,
exc,
wait,
)
_set(job, stage=f"Network error — retrying ({attempt + 1}/{_MAX_RETRIES})...")
time.sleep(wait)
else:
raise
_set(
job,
title=info.get("title") or meta.get("title"),
duration_sec=info.get("duration") or duration,
thumbnail=info.get("thumbnail") or meta.get("thumbnail"),
)
raw_tags = [
t.strip().lower()
for t in (info.get("tags") or []) + (info.get("categories") or [])
if isinstance(t, str) and t.strip()
]
seen: set[str] = set()
deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value]
_set(job, tags=deduped[:8] or None)
# Best-effort: fetch the real video stream for the MP4 export.
# Non-fatal -- on any failure the job proceeds audio-only.
if is_youtube:
_download_video_track(job, url, job_dir)
candidates = sorted(job_dir.glob("source.*"))
if not candidates:
raise RuntimeError("yt-dlp finished but no source file was produced")
logger.info("[%s] download complete: %s", job.id, candidates[0].name)
return candidates[0]
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import subprocess
from pathlib import Path
from app.core.config import TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled, _set
from app.core.registry import persist as persist_registry
from app.pipeline.analyze import analyze, compute_stem_presence
from app.pipeline.collect import (
cleanup_source,
collect,
compute_stem_peaks,
make_original_track,
make_selected_mix,
)
from app.pipeline.download import download
from app.pipeline.separate import separate
logger = logging.getLogger("stemdeck.pipeline")
def _rmtree(path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
pass
except Exception:
logger.warning("failed to remove %s", path, exc_info=True)
# Only one heavy job runs at a time -- Demucs is GPU/CPU-hungry.
_pipeline_lock = asyncio.Semaphore(1)
def _check_cancel(job: Job) -> None:
if job.cancel_requested:
raise JobCancelled()
def _extract_video_track(job: Job, source: Path, job_dir: Path) -> None:
"""For an .mp4 upload, preserve a silent video-only track at
video.mp4 so the studio can later mux it with a custom stem mix
into an MP4 (issue #219). Stream-copies the video (no
re-encode) -- fast and lossless.
Best-effort: an .mp4 with no video stream (audio-only container)
fails harmlessly and leaves has_video false."""
from app.core.config import ffmpeg_executable
dest = job_dir / "video.mp4"
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-i",
str(source),
"-an", # drop audio -- the mix is added at export time
"-c:v",
"copy",
"-movflags",
"+faststart",
"-y",
str(dest),
]
result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG)
if result.returncode != 0 or not dest.is_file() or dest.stat().st_size == 0:
dest.unlink(missing_ok=True)
logger.info("no video track preserved for job %s (source has no video stream?)", job.id)
return
job.has_video = True
def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path:
"""Transcode any local upload to 16-bit 44.1 kHz stereo WAV before
handing it to Demucs. Normalises MP3 and non-standard WAV formats
(24-bit, 32-bit float, high sample rate, multi-channel) that Demucs
would otherwise process silently and output as silence.
For .mp4 uploads, first preserves a silent video.mp4 for later
MP4 export. Deletes the original source file after a
successful transcode."""
from app.core.config import ffmpeg_executable
dest = job_dir / "source.wav"
if source.resolve() == dest.resolve():
return source
_set(job, stage="Preparing audio...")
if source.suffix.lower() == ".mp4":
_extract_video_track(job, source, job_dir)
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-i",
str(source),
"-ar",
"44100",
"-ac",
"2",
"-sample_fmt",
"s16",
"-y",
str(dest),
]
result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG)
if result.returncode != 0:
raise RuntimeError(
"ffmpeg transcode failed: " + result.stderr.decode("utf-8", errors="replace").strip()
)
source.unlink(missing_ok=True)
return dest
def _run_common(job: Job, source: Path, job_dir: Path) -> None:
"""Analyze → separate → collect → mix. Shared by both YouTube and local
upload pipelines after their respective source acquisition steps."""
_check_cancel(job)
analyze(job, source)
_check_cancel(job)
stems_root = separate(job, source, job_dir)
found = collect(job, stems_root, job_dir)
stems_dir = job_dir / "stems"
job.stem_presence = compute_stem_presence(stems_dir, found)
# Source (100-300 MB or the local upload) is no longer needed after
# collect; delete it before the ffmpeg amix steps in case scratch space
# is tight.
cleanup_source(job_dir)
job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found]
_check_cancel(job)
_set(job, stage="Mixing tracks...")
original_path = make_original_track(job, job_dir, stems_dir)
if original_path is not None:
job.stems.insert(
0,
{
"name": "original",
"url": f"/api/jobs/{job.id}/stems/original.wav",
},
)
_check_cancel(job)
mix_path = make_selected_mix(job, stems_dir, found)
if mix_path is not None:
job.mix_url = f"/api/jobs/{job.id}/stems/{mix_path.name}"
_check_cancel(job)
all_stem_names = [s["name"] for s in job.stems]
if mix_path is not None and mix_path.stem not in all_stem_names:
all_stem_names.append(mix_path.stem)
compute_stem_peaks(stems_dir, all_stem_names)
def _run_blocking(job: Job, url: str, job_dir: Path) -> None:
_check_cancel(job)
source = download(job, url, job_dir)
_run_common(job, source, job_dir)
def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None:
_check_cancel(job)
source = _prepare_local_source(job, source_path, job_dir)
_run_common(job, source, job_dir)
def _write_metadata(job: Job, job_dir: Path) -> None:
meta = {
"title": job.title,
"thumbnail": job.thumbnail,
"duration_sec": job.duration_sec,
"bpm": job.bpm,
"key": job.key,
"scale": job.scale,
"key_confidence": job.key_confidence,
"lufs": job.lufs,
"peak_db": job.peak_db,
"dynamic_range": job.dynamic_range,
"tempo_stability": job.tempo_stability,
"stem_presence": job.stem_presence,
"tags": job.tags,
"has_video": job.has_video,
}
try:
(job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
except OSError:
logger.warning("could not write metadata.json for job %s", job.id, exc_info=True)
async def _run_async(
job: Job,
job_dir: Path,
jobs_dir: Path,
blocking_fn,
*fn_args: object,
error_msg: str = "Audio processing failed. Please try again.",
) -> None:
"""Common async wrapper: acquires the pipeline lock, runs blocking_fn in a
thread, then handles success / cancel / error outcomes uniformly."""
try:
async with _pipeline_lock:
await asyncio.to_thread(blocking_fn, job, *fn_args, job_dir)
except Exception as e:
if not isinstance(e, JobCancelled) and not job.cancel_requested:
logger.exception("pipeline failed for job %s: %s", job.id, e)
_set(job, status="error", stage="Error: Processing failed", error=error_msg)
persist_registry(jobs_dir)
_rmtree(job_dir)
return
logger.info(
"pipeline cancelled%s for job %s",
" (wrapped)" if not isinstance(e, JobCancelled) else "",
job.id,
)
_set(job, status="cancelled", stage="Cancelled")
persist_registry(jobs_dir)
_rmtree(job_dir)
return
_set(job, status="done", progress=1.0, stage="Done")
_write_metadata(job, job_dir)
persist_registry(jobs_dir)
async def run_pipeline(job: Job, url: str, jobs_dir: Path) -> None:
job_dir = jobs_dir / job.id
try:
job_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.exception("pipeline failed for job %s: %s", job.id, e)
_set(
job,
status="error",
stage="Error: Processing failed",
error="Audio processing failed. Please try another video.",
)
persist_registry(jobs_dir)
return
await _run_async(
job,
job_dir,
jobs_dir,
_run_blocking,
url,
error_msg="Audio processing failed. Please try another video.",
)
async def run_local_pipeline(job: Job, source_path: Path, jobs_dir: Path) -> None:
"""Run the stem-separation pipeline for a locally uploaded file.
The job directory and source file are already present on disk (created
by the API handler before this task is scheduled)."""
job_dir = jobs_dir / job.id
await _run_async(job, job_dir, jobs_dir, _run_local_blocking, source_path)
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import logging
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL
from app.core.models import Job, JobCancelled, _set
from app.core.registry import set_proc
logger = logging.getLogger("stemdeck.pipeline")
_PCT_RE = re.compile(r"(\d{1,3})%")
# Terminate demucs if stderr produces no output for this many seconds.
# GPU processing can be silent for minutes; 30 min covers legitimate pauses
# while still catching genuine hangs (GPU deadlock, OOM stall, etc.).
def separate(job: Job, source: Path, job_dir: Path) -> Path:
_set(job, status="separating", progress=0.0, stage="Separating stems...")
cmd = [
sys.executable,
"-m",
"demucs",
"-n",
DEMUCS_MODEL,
"-d",
DEMUCS_DEVICE,
"-o",
str(job_dir),
str(source),
]
env = os.environ.copy()
try:
import certifi
env.setdefault("SSL_CERT_FILE", certifi.where())
env.setdefault("REQUESTS_CA_BUNDLE", certifi.where())
except ModuleNotFoundError:
pass
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=0,
env=env,
)
if proc.stderr is None:
raise RuntimeError("demucs subprocess has no stderr pipe")
set_proc(job.id, proc)
# tqdm uses \r to redraw -- read char-by-char and split on \r or \n.
# Keep the last few non-progress lines so we can surface them if demucs
# exits non-zero (otherwise the only signal would be a bare exit code).
buf = ""
tail: list[str] = []
last_output: list[float] = [time.monotonic()]
# Event set by the reader loop when the process exits normally so the
# watchdog can wake up immediately instead of waiting out its 30 s sleep.
_done_evt = threading.Event()
def _watchdog() -> None:
while not _done_evt.wait(timeout=30):
if proc.poll() is not None:
return
if time.monotonic() - last_output[0] > TIMEOUT_DEMUCS_STALL:
logger.warning(
"demucs stalled for %ss with no output, terminating job %s",
TIMEOUT_DEMUCS_STALL,
job.id,
)
proc.terminate()
return
wt = threading.Thread(target=_watchdog, daemon=True)
wt.start()
try:
while True:
ch = proc.stderr.read(1)
if not ch:
break
last_output[0] = time.monotonic()
if ch in ("\r", "\n"):
line = buf.strip()
buf = ""
if not line:
continue
m = _PCT_RE.search(line)
if m:
pct = max(0, min(100, int(m.group(1))))
_set(job, progress=pct / 100.0, stage=f"Separating {pct}%")
else:
tail.append(line)
if len(tail) > 40:
tail.pop(0)
else:
buf += ch
proc.wait()
finally:
_done_evt.set()
set_proc(job.id, None)
wt.join(timeout=2)
# POST /cancel calls proc.terminate() directly, which causes the read loop
# above to hit EOF and proc.wait() to return a nonzero status. Translate
# that into JobCancelled before the generic "demucs failed" path.
if job.cancel_requested:
raise JobCancelled()
if proc.returncode != 0:
detail = "\n".join(tail[-15:]) if tail else "(no stderr captured)"
logger.error("[%s] demucs exited %s; tail:\n%s", job.id, proc.returncode, detail)
last = tail[-1] if tail else f"exit status {proc.returncode}"
raise RuntimeError(f"demucs failed: {last}")
stems_root = job_dir / DEMUCS_MODEL / source.stem
if not stems_root.is_dir():
raise RuntimeError(f"demucs output not found at {stems_root}")
return stems_root
+112
View File
@@ -0,0 +1,112 @@
# Two-stage build:
# * builder -- has gcc/build-essential/curl/unzip/pip etc.; produces
# an isolated /opt/venv with all Python deps installed
# and a fetched deno binary.
# * runner -- only the runtime needs: Python (slim), ffmpeg,
# ca-certificates, the venv copied from the builder,
# and our app code. No compilers, no pip cache, no
# download tooling -- shaves ~150-300 MB off the
# single-stage image and reduces attack surface.
# ─── Stage 1: builder ────────────────────────────────────────────
FROM python:3.12-slim AS builder
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Build deps for pip wheels that fall back to source compilation
# (numpy/scipy ship manylinux wheels, but smaller deps occasionally
# need a compiler). Plus curl/unzip to fetch deno.
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
ca-certificates \
unzip \
&& rm -rf /var/lib/apt/lists/*
# deno -- yt-dlp uses it as the JS runtime for YouTube format
# extraction. Staged here so the runner doesn't need curl/unzip.
ARG TARGETARCH
RUN case "${TARGETARCH:-$(dpkg --print-architecture)}" in \
amd64) DENO_ARCH=x86_64-unknown-linux-gnu ;; \
arm64) DENO_ARCH=aarch64-unknown-linux-gnu ;; \
*) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; \
esac \
&& curl -fsSL -o /tmp/deno.zip "https://github.com/denoland/deno/releases/latest/download/deno-${DENO_ARCH}.zip" \
&& unzip /tmp/deno.zip -d /usr/local/bin \
&& rm /tmp/deno.zip \
&& /usr/local/bin/deno --version
# Isolated venv -- the runner stage copies /opt/venv wholesale,
# inheriting all installed deps without dragging in pip caches,
# /var/lib/apt state, or build tools.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY pyproject.toml ./
COPY app/ ./app/
COPY static/ ./static/
# The version is git-derived (hatch-vcs), but the build context has no .git, so
# pin it explicitly: `docker build --build-arg VERSION=0.7.0-alpha.4 ...`
# (defaults to 0.0.0 for ad-hoc local builds). #169
ARG VERSION=0.0.0
RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300
# ─── Stage 2: runner ─────────────────────────────────────────────
FROM python:3.12-slim AS runner
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TORCH_HOME=/cache/torch \
XDG_CACHE_HOME=/cache
# Runtime-only OS deps. ffmpeg has many shared libs (libav*, libsw*,
# libpostproc), so we install via apt rather than copy-pasting binaries
# and chasing transitive .so dependencies. ca-certificates is needed
# for HTTPS to YouTube.
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
gosu \
&& rm -rf /var/lib/apt/lists/*
# Deno binary fetched in stage 1 (single self-contained file).
COPY --from=builder /usr/local/bin/deno /usr/local/bin/deno
# Pre-built Python virtualenv with all our deps. The PATH env above
# means `python`, `uvicorn`, `pip`, etc. resolve into the venv with
# no further configuration.
COPY --from=builder /opt/venv /opt/venv
# Application code -- copied to /app rather than only relied-on via
# site-packages because app/core/config.py derives STATIC_DIR from
# __file__'s parent chain, and the cwd-rooted /app/app version sits
# earlier on sys.path than the venv install of `app`. Without the
# COPY, STATIC_DIR would resolve into site-packages where static/
# wasn't included by hatchling's wheel target.
WORKDIR /app
COPY app/ ./app/
COPY static/ ./static/
# Create the app user and own all non-bind-mounted paths. /app/jobs is a
# bind mount at runtime -- the entrypoint script re-chowns it to app:app
# before dropping privileges, handling the case where Docker creates the
# host directory as root on first run.
RUN groupadd --system --gid 1001 app \
&& useradd --system --uid 1001 --gid app --home /app --no-create-home app \
&& mkdir -p /cache /jobs \
&& chown -R app:app /app /cache /jobs
COPY build/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+26
View File
@@ -0,0 +1,26 @@
# Explicit name so compose doesn't derive it from the parent directory
# (which is now `build/`, giving misleading object names like
# `build_default` for the network).
name: stemdeck
services:
stemdeck:
# Compose file lives in build/, so the build context is the parent
# (project root) and the Dockerfile sits next to this compose file.
build:
context: ..
dockerfile: build/Dockerfile
image: stemdeck
container_name: stemdeck
ports:
- "8000:8000"
volumes:
# Stems and downloaded audio land in <project root>/jobs on the
# host so you can grab them without going through the container.
- ../jobs:/app/jobs
# Demucs model weights (~170 MB) survive container rebuilds.
- stemdeck-cache:/cache
restart: unless-stopped
volumes:
stemdeck-cache:
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# Run as the requested UID/GID so files written to the mounted appdata paths are
# owned consistently. This matches the Unraid/NAS convention (defaults there are
# nobody:users = 99:100). When PUID/PGID are unset, fall back to the image's
# original non-root app user (1001), preserving prior behaviour.
PUID="${PUID:-1001}"
PGID="${PGID:-1001}"
# Chown the only paths the app writes to before dropping privileges:
# /app/jobs registry.json, downloaded audio, and stems
# /cache torch/Demucs model weights (TORCH_HOME, XDG_CACHE_HOME)
# /app/settings.json best-effort settings persistence (created on demand)
# App code and the venv under /app stay world-readable, so a different UID can
# still import and run them. Re-chowning is also what fixes a bind mount that
# Docker created as root on first run.
chown -R "${PUID}:${PGID}" /app/jobs /cache 2>/dev/null || true
touch /app/settings.json 2>/dev/null && chown "${PUID}:${PGID}" /app/settings.json 2>/dev/null || true
# Drop to the target user and exec the CMD. gosu accepts a numeric UID:GID even
# when no matching named user exists.
exec gosu "${PUID}:${PGID}" "$@"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<CommunityApplications>
<Profile>
StemDeck splits any song into isolated stems (vocals, drums, bass, guitar,
piano, other) and plays them back in a DAW-style multitrack player, for
practice and transcription. This repository maintains the official StemDeck
self-hosted server container. For help, open an issue on GitHub.
</Profile>
<Icon>https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/desktop/src-tauri/icons/icon.png</Icon>
<WebPage>https://github.com/stemdeckapp/stemdeck</WebPage>
<Forum>https://github.com/stemdeckapp/stemdeck/issues</Forum>
</CommunityApplications>
+247
View File
@@ -0,0 +1,247 @@
{
"name": "stemdeck-desktop",
"version": "0.1.0-alpha.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stemdeck-desktop",
"version": "0.1.0-alpha.0",
"devDependencies": {
"@tauri-apps/cli": "^2.0.0"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz",
"integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.1",
"@tauri-apps/cli-darwin-x64": "2.11.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.1",
"@tauri-apps/cli-linux-arm64-musl": "2.11.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-gnu": "2.11.1",
"@tauri-apps/cli-linux-x64-musl": "2.11.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.1",
"@tauri-apps/cli-win32-x64-msvc": "2.11.1"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz",
"integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz",
"integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz",
"integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz",
"integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz",
"integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz",
"integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz",
"integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz",
"integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz",
"integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz",
"integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz",
"integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "stemdeck-desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0"
}
}
+5294
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "stemdeck"
# Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169
version = "0.0.0"
description = "StemDeck desktop launcher"
authors = ["StemDeck contributors"]
edition = "2021"
rust-version = "1.88"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
flate2 = "1"
libc = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tar = "0.4"
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
zstd = "0.13"
[dev-dependencies]
tempfile = "3"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default StemDeck desktop window permissions",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-save"]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "StemDeck",
"version": "0.0.0",
"identifier": "app.stemdeck.desktop",
"build": {
"frontendDist": "../ui",
"beforeDevCommand": "",
"beforeBuildCommand": ""
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "StemDeck",
"width": 1280,
"height": 820,
"minWidth": 1024,
"minHeight": 720,
"backgroundColor": "#050b10",
"dragDropEnabled": false
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": ["icons/icon.icns", "icons/icon.png", "icons/icon.ico"],
"macOS": {
"minimumSystemVersion": "13.0",
"dmg": {
"windowSize": {
"width": 660,
"height": 400
},
"appPosition": {
"x": 180,
"y": 170
},
"applicationFolderPosition": {
"x": 480,
"y": 170
}
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StemDeck Launcher</title>
<link rel="stylesheet" href="./setup.css" />
</head>
<body>
<main class="setup-shell">
<section class="setup-card">
<div class="brand-row">
<h1>StemDeck</h1>
<span>ALPHA</span>
</div>
<p class="subtitle">Preparing the local audio engine, first run will take a while...</p>
<ol class="setup-steps" id="steps">
<li data-step="runtime">Setting up local runtime</li>
<li data-step="workspace">Creating workspace</li>
<li data-step="ffmpeg">Checking FFmpeg</li>
<li data-step="gpu">Configuring compute device</li>
<li data-step="model">Checking AI separation model</li>
<li data-step="backend">Starting backend</li>
</ol>
<p class="status" id="status">Starting setup...</p>
<div class="progress-wrap hidden" id="progress-wrap">
<div class="progress-fill" id="progress-fill"></div>
</div>
<pre class="details hidden" id="details"></pre>
<div class="actions">
<button id="retry" type="button" class="hidden">Retry</button>
</div>
</section>
</main>
<script type="module" src="./setup.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"version": "0.1.0-alpha.1",
"arch": "arm64",
"runtimeUrl": "",
"runtimeSha256": "",
"runtimeSize": 0,
"archiveName": "StemDeck-runtime-macOS-arm64.tar.zst"
}
+215
View File
@@ -0,0 +1,215 @@
:root {
color-scheme: dark;
--bg: #050b10;
--panel: #101a22;
--line: rgba(148, 163, 184, 0.2);
--ink: #f0f2f5;
--muted: #9aa3ad;
--gold: #d8a84a;
--ok: #54c568;
--err: #ef4f58;
font-family:
"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 80% 10%, rgba(216, 168, 74, 0.1), transparent 26rem),
radial-gradient(circle at 12% 92%, rgba(62, 141, 207, 0.09), transparent 24rem),
var(--bg);
color: var(--ink);
}
.setup-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.setup-card {
width: min(760px, 100%);
padding: 32px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent), var(--panel);
box-shadow: 0 20px 90px rgba(0, 0, 0, 0.34);
}
.brand-row {
display: flex;
align-items: center;
gap: 12px;
}
h1 {
margin: 0;
font-size: 32px;
letter-spacing: 0;
}
.brand-row span {
padding: 3px 7px;
border: 1px solid rgba(216, 168, 74, 0.58);
border-radius: 7px;
color: #f1cf82;
font-size: 13px;
}
.subtitle {
margin: 8px 0 26px;
color: var(--muted);
}
.setup-steps {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.setup-steps li {
display: flex;
align-items: center;
gap: 12px;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 8px;
background: rgba(3, 10, 15, 0.34);
color: rgba(154, 163, 173, 0.45); /* dimmed — queued */
transition: color 240ms ease, border-color 240ms ease;
}
/* pending: small hollow ring, barely visible */
.setup-steps li::before {
content: "";
flex: 0 0 auto;
width: 12px;
height: 12px;
border: 1.5px solid currentColor;
border-radius: 50%;
opacity: 0.5;
}
/* active: gold spinning ring */
.setup-steps li.active {
color: var(--gold);
border-color: rgba(216, 168, 74, 0.22);
}
.setup-steps li.active::before {
opacity: 1;
border-color: var(--gold);
border-top-color: transparent;
animation: spin 750ms linear infinite;
}
/* done: bright green ✓ */
.setup-steps li.done {
color: var(--ok);
border-color: rgba(84, 197, 104, 0.18);
}
.setup-steps li.done::before {
content: "✓";
width: auto;
height: auto;
border: 0;
opacity: 1;
font-size: 14px;
line-height: 1;
animation: none;
}
/* error: red ✗ */
.setup-steps li.error {
color: var(--err);
border-color: rgba(239, 79, 88, 0.25);
}
.setup-steps li.error::before {
content: "✗";
width: auto;
height: auto;
border: 0;
opacity: 1;
font-size: 14px;
line-height: 1;
animation: none;
}
.status {
margin: 24px 0 0;
color: #cbd2da;
}
.progress-wrap {
height: 4px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--gold);
border-radius: 2px;
width: 0%;
transition: width 0.15s linear;
}
.progress-fill.indeterminate {
width: 35%;
animation: progress-slide 1.6s ease-in-out infinite;
}
@keyframes progress-slide {
0% { transform: translateX(-200%); }
100% { transform: translateX(400%); }
}
.details {
max-height: 180px;
overflow: auto;
margin: 16px 0 0;
padding: 12px;
border: 1px solid rgba(239, 79, 88, 0.25);
border-radius: 8px;
color: #ffd0d4;
background: rgba(239, 79, 88, 0.08);
white-space: pre-wrap;
}
.actions {
margin-top: 20px;
}
button {
min-height: 42px;
padding: 0 18px;
border: 1px solid rgba(216, 168, 74, 0.4);
border-radius: 8px;
background: rgba(216, 168, 74, 0.14);
color: #f1cf82;
font: inherit;
cursor: pointer;
}
.hidden {
display: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+383
View File
@@ -0,0 +1,383 @@
const { invoke } = window.__TAURI__.core;
let _runtimeUnlisten = null;
// The backend always binds all interfaces; network availability is gated live
// by the backend itself (Settings → "Make StemDeck available on your network").
function startBackend() {
return invoke("start_backend");
}
const statusEl = document.getElementById("status");
const detailsEl = document.getElementById("details");
const retryBtn = document.getElementById("retry");
const steps = [...document.querySelectorAll("[data-step]")];
function setStep(name, state) {
const el = steps.find((item) => item.dataset.step === name);
if (!el) return;
el.classList.remove("active", "done", "error");
if (state) el.classList.add(state);
}
function setStatus(message) {
statusEl.textContent = message;
}
function showError(error, hint) {
for (const el of steps) {
if (el.classList.contains("active")) {
el.classList.replace("active", "error");
}
}
setStatus("Setup could not complete.");
const msg = String(error?.message ?? error);
detailsEl.textContent = hint ? `${msg}\n\n${hint}` : msg;
detailsEl.classList.remove("hidden");
retryBtn.classList.remove("hidden");
}
async function runStep(name, fn) {
setStep(name, "active");
try {
const result = await fn();
setStep(name, "done");
return result;
} catch (err) {
setStep(name, "error");
throw err;
}
}
function minDelay(ms) {
return Promise.all([
new Promise((r) => setTimeout(r, ms)),
new Promise((r) => requestAnimationFrame(r)),
]);
}
function formatElapsed(startedAt) {
const seconds = Math.floor((Date.now() - startedAt) / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
}
function startProgressStatus(messages) {
const startedAt = Date.now();
let messageIndex = 0;
const update = () => {
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000);
while (
messageIndex + 1 < messages.length &&
elapsedSeconds >= messages[messageIndex + 1].afterSeconds
) {
messageIndex += 1;
}
setStatus(`${messages[messageIndex].text} Elapsed: ${formatElapsed(startedAt)}.`);
};
update();
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}
function isMac() {
return /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform ?? "");
}
async function installRuntimePack(appRoot) {
const status = await invoke("runtime_pack_status");
if (!status.manifestReady) {
throw Object.assign(
new Error(`Python runtime not found under ${appRoot}.`),
{ hint: "Try reinstalling StemDeck. If the problem persists, check that your disk has at least 2 GB free." }
);
}
const progressWrap = document.getElementById("progress-wrap");
const progressFill = document.getElementById("progress-fill");
// lastProgressAt is updated by the SSE handler below; only meaningful
// during a network download, so it is reset just before download starts.
let lastReceived = 0;
let lastProgressAt = Date.now();
const STALL_WARN_MS = 30_000;
// Guard against stacked listeners on rapid retry (#146): unlisten any
// previous registration before creating a new one.
if (_runtimeUnlisten) { _runtimeUnlisten(); _runtimeUnlisten = null; }
const unlisten = await window.__TAURI__.event.listen(
"runtime-download-progress",
(event) => {
const { received, total } = event.payload;
if (received !== lastReceived) {
lastReceived = received;
lastProgressAt = Date.now();
}
const mb = (received / 1e6).toFixed(0);
if (total && total > 0) {
const pct = Math.min(100, Math.round((received / total) * 100));
progressFill.style.width = `${pct}%`;
progressFill.classList.remove("indeterminate");
setStatus(`Downloading StemDeck runtime... ${mb} / ${(total / 1e6).toFixed(0)} MB`);
} else {
progressFill.classList.add("indeterminate");
setStatus(`Downloading StemDeck runtime... ${mb} MB received`);
}
}
);
_runtimeUnlisten = unlisten;
progressWrap.classList.remove("hidden");
progressFill.style.width = "0%";
progressFill.classList.remove("indeterminate");
try {
let verified = false;
if (status.archiveReady) {
try {
setStatus("Runtime archive found locally, verifying...");
progressWrap.classList.add("hidden");
await invoke("verify_runtime_pack");
verified = true;
} catch {
// Stale or corrupt archive — fall through to re-download
}
}
if (!verified) {
progressWrap.classList.remove("hidden");
setStatus("Downloading StemDeck runtime...");
// Reset stall baseline when network download is actually about to start (#150).
lastProgressAt = Date.now();
// stallTimer starts here, not at top of function, so it doesn't fire
// during the local verify_runtime_pack path (#150).
// When a stall is detected it stops startProgressStatus first to avoid
// both timers writing setStatus concurrently (#149).
let stopSlowMsg = null;
const stallTimer = window.setInterval(() => {
const stallMs = Date.now() - lastProgressAt;
if (stallMs >= STALL_WARN_MS) {
if (stopSlowMsg) { stopSlowMsg(); stopSlowMsg = null; }
setStatus(
stallMs >= 60_000
? "Download appears unreachable — check your internet connection and click Retry."
: "Download seems slow or stalled — check your internet connection."
);
}
}, 5_000);
// startProgressStatus is assigned after stallTimer creation; the closure
// above captures stopSlowMsg by reference, so it sees the updated value.
stopSlowMsg = startProgressStatus([
{ afterSeconds: 0, text: "Downloading StemDeck runtime..." },
{ afterSeconds: 30, text: "Still downloading runtime... slow connection detected." },
{ afterSeconds: 90, text: "Still downloading... large file on a slow connection can take a few minutes." },
]);
try {
await invoke("download_runtime_pack");
} catch (err) {
throw Object.assign(
new Error(String(err)),
{ hint: "Check your internet connection and click Retry. If the problem persists, try a different network." }
);
} finally {
window.clearInterval(stallTimer);
if (stopSlowMsg) { stopSlowMsg(); }
}
progressWrap.classList.add("hidden");
setStatus("Verifying StemDeck runtime...");
await invoke("verify_runtime_pack");
}
setStatus("Installing StemDeck runtime...");
const installed = await invoke("extract_runtime_pack");
if (!installed.runtimeReady) {
throw Object.assign(
new Error("Runtime install finished but Python/backend files were not found."),
{ hint: "Your disk may be full or the archive may be corrupt. Free up space and click Retry to re-download." }
);
}
} finally {
_runtimeUnlisten = null;
unlisten();
progressWrap.classList.add("hidden");
}
}
async function runSetup() {
detailsEl.classList.add("hidden");
retryBtn.classList.add("hidden");
for (const step of steps) step.classList.remove("active", "done", "error");
try {
setStep("runtime", "active");
setStatus("Checking Python runtime...");
let [runtime] = await Promise.all([
invoke("probe_runtime"),
minDelay(350),
]);
// Compare the installed runtime against the version this app bundle expects.
// On a DMG upgrade the old runtime is still fully "ready", so this MUST be
// checked before the early-return below -- otherwise setup starts the stale
// backend + frontend and the new release (e.g. new features, version) never
// takes effect until the runtime is manually cleared.
const runtimeStatus = await invoke("runtime_pack_status");
const expectedVersion = runtimeStatus.manifest?.version;
const installedVersion = runtimeStatus.installedVersion;
// Mismatch when this build expects a version the installed runtime isn't.
// An unknown installedVersion (a runtime from a build that never recorded
// one) also counts, so an upgrade still refreshes it. Self-heals: after one
// refresh the install records the version and subsequent launches match.
const versionMismatch = Boolean(expectedVersion) && installedVersion !== expectedVersion;
if (runtime.pythonReady && runtime.ffmpegReady && runtime.torchDevice && !versionMismatch) {
for (const step of steps) {
step.classList.remove("active", "error");
if (step.dataset.step === "backend") {
step.classList.remove("done");
} else {
step.classList.add("done");
}
}
await runStep("backend", async () => {
setStatus("Runtime is ready. Starting StemDeck backend...");
const backend = await startBackend();
setStatus("Opening StemDeck...");
window.location.replace(backend.url);
});
return;
}
if (!runtime.pythonReady || versionMismatch) {
if (versionMismatch) {
setStatus(`Updating runtime from ${installedVersion || "an older build"} to ${expectedVersion}...`);
}
await invoke("ensure_workspace");
await installRuntimePack(runtime.appRoot);
runtime = await invoke("probe_runtime");
if (!runtime.pythonReady) {
setStep("runtime", "error");
throw Object.assign(
new Error(`Python runtime setup failed under: ${runtime.dataDir}`),
{ hint: "Check that your disk has at least 2 GB free and click Retry. If it keeps failing, try reinstalling StemDeck." }
);
}
}
setStep("runtime", "done");
setStatus(`Python runtime found at ${runtime.pythonPath}`);
await minDelay(200);
let gpuSummary = "";
await runStep("workspace", () => invoke("ensure_workspace"));
if (runtime.ffmpegReady) {
setStep("ffmpeg", "done");
} else {
await runStep("ffmpeg", async () => {
const stopProgress = startProgressStatus([
{
afterSeconds: 0,
text: "Downloading FFmpeg... this can take a few minutes on first run.",
},
{
afterSeconds: 60,
text: "Still downloading FFmpeg... slow networks or antivirus scans can delay this.",
},
]);
try {
const assets = await invoke("ensure_external_assets");
if (!assets.ffmpegReady) {
throw new Error(
"FFmpeg setup did not complete. Check your internet connection and retry."
);
}
} finally {
stopProgress();
}
});
}
await runStep("gpu", async () => {
const macGPU = isMac();
const stopProgress = startProgressStatus(
macGPU
? [
{
afterSeconds: 0,
text: "Checking Apple Silicon compute support...",
},
{
afterSeconds: 10,
text: "Verifying MPS acceleration for AI models...",
},
]
: [
{
afterSeconds: 0,
text: "Checking NVIDIA GPU and compute support...",
},
{
afterSeconds: 20,
text: "Installing NVIDIA acceleration if needed... first run can take 5-15 minutes.",
},
{
afterSeconds: 120,
text: "Still installing NVIDIA acceleration... CUDA Torch packages are large.",
},
{
afterSeconds: 300,
text: "Still working... setup should finish or time out automatically.",
},
]
);
try {
const gpu = await invoke("ensure_torch_device");
if (macGPU) {
gpuSummary =
gpu.torchDevice === "mps"
? `${gpu.gpuName} acceleration enabled`
: "MPS acceleration unavailable - stem separation will use CPU";
} else {
if (gpu.gpuDetected && !gpu.cudaVerified) {
showError(
`GPU detected (${gpu.gpuName}) but CUDA setup failed - stem separation will use CPU.\nCheck logs/setup.log in the StemDeck data folder for details.`
);
}
gpuSummary = gpu.gpuDetected
? gpu.cudaVerified
? `${gpu.gpuName} - CUDA ${gpu.cudaVersion} enabled`
: `${gpu.gpuName} found - falling back to CPU (CUDA unverified)`
: "No NVIDIA GPU - stem separation will use CPU";
}
return gpu;
} finally {
stopProgress();
}
});
setStep("model", "done");
setStatus("AI separation model will download on first use (~340 MB).");
await runStep("backend", async () => {
setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting StemDeck backend...");
const backend = await startBackend();
setStatus("Opening StemDeck...");
window.location.replace(backend.url);
});
} catch (error) {
showError(error, error?.hint);
}
}
retryBtn.addEventListener("click", runSetup);
runSetup();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,28 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck App Icon</title>
<desc id="desc">Dark rounded square app icon with a golden segmented waveform.</desc>
<defs>
<linearGradient id="bg" x1="96" y1="48" x2="416" y2="464" gradientUnits="userSpaceOnUse">
<stop stop-color="#16212A"/>
<stop offset="1" stop-color="#081018"/>
</linearGradient>
<linearGradient id="gold" x1="128" y1="96" x2="384" y2="416" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.45" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<rect x="41" y="41" width="430" height="430" rx="103" stroke="#263744" stroke-opacity="0.72" stroke-width="2"/>
<g fill="url(#gold)" transform="translate(-26.5 0)">
<rect x="118" y="248" width="20" height="16" rx="8"/>
<rect x="152" y="205" width="24" height="102" rx="12"/>
<rect x="190" y="158" width="26" height="196" rx="13"/>
<rect x="230" y="212" width="22" height="88" rx="11"/>
<rect x="270" y="110" width="28" height="292" rx="14"/>
<rect x="315" y="190" width="24" height="132" rx="12"/>
<rect x="354" y="156" width="26" height="200" rx="13"/>
<rect x="394" y="214" width="24" height="84" rx="12"/>
<rect x="427" y="248" width="20" height="16" rx="8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,25 @@
<svg width="1200" height="320" viewBox="0 0 1200 320" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck Horizontal Logo</title>
<desc id="desc">StemDeck logo with golden waveform symbol and wordmark.</desc>
<defs>
<linearGradient id="gold" x1="82" y1="48" x2="282" y2="264" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.48" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<g fill="url(#gold)">
<rect x="78" y="154" width="16" height="12" rx="6"/>
<rect x="110" y="118" width="20" height="84" rx="10"/>
<rect x="144" y="78" width="22" height="164" rx="11"/>
<rect x="178" y="126" width="18" height="68" rx="9"/>
<rect x="212" y="52" width="24" height="216" rx="12"/>
<rect x="250" y="106" width="20" height="108" rx="10"/>
<rect x="284" y="80" width="22" height="160" rx="11"/>
<rect x="318" y="128" width="20" height="64" rx="10"/>
<rect x="352" y="154" width="16" height="12" rx="6"/>
</g>
<text x="430" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F4F6F8">Stem</text>
<text x="678" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F2B53D">Deck</text>
<text x="435" y="232" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="28" font-weight="500" letter-spacing="11" fill="#7F8A94">POWERED STEM SEPARATION</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,24 @@
<svg width="720" height="560" viewBox="0 0 720 560" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck Stacked Logo</title>
<desc id="desc">Stacked StemDeck logo with waveform symbol above the wordmark.</desc>
<defs>
<linearGradient id="gold" x1="230" y1="58" x2="490" y2="318" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.48" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<g fill="url(#gold)">
<rect x="208" y="184" width="16" height="12" rx="6"/>
<rect x="244" y="140" width="24" height="100" rx="12"/>
<rect x="288" y="84" width="26" height="212" rx="13"/>
<rect x="334" y="150" width="22" height="80" rx="11"/>
<rect x="378" y="54" width="28" height="272" rx="14"/>
<rect x="426" y="126" width="24" height="128" rx="12"/>
<rect x="470" y="86" width="26" height="208" rx="13"/>
<rect x="516" y="154" width="24" height="72" rx="12"/>
<rect x="552" y="184" width="16" height="12" rx="6"/>
</g>
<text x="388" y="424" text-anchor="middle" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="96" font-weight="700" letter-spacing="-4"><tspan fill="#F4F6F8">Stem </tspan><tspan fill="#F2B53D">Deck</tspan></text>
<text x="388" y="480" text-anchor="middle" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="24" font-weight="500" letter-spacing="8" fill="#7F8A94">POWERED STEM SEPARATION</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title">
<title id="title">StemDeck Tray Icon Light</title>
<g fill="#F5B93F">
<rect x="9" y="30" width="4" height="4" rx="2"/>
<rect x="16" y="23" width="5" height="18" rx="2.5"/>
<rect x="24" y="15" width="5" height="34" rx="2.5"/>
<rect x="32" y="25" width="4" height="14" rx="2"/>
<rect x="39" y="18" width="5" height="28" rx="2.5"/>
<rect x="48" y="25" width="5" height="14" rx="2.5"/>
<rect x="56" y="30" width="4" height="4" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 609 B

@@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title">
<title id="title">StemDeck Tray Icon Monochrome</title>
<g fill="currentColor">
<rect x="9" y="30" width="4" height="4" rx="2"/>
<rect x="16" y="23" width="5" height="18" rx="2.5"/>
<rect x="24" y="15" width="5" height="34" rx="2.5"/>
<rect x="32" y="25" width="4" height="14" rx="2"/>
<rect x="39" y="18" width="5" height="28" rx="2.5"/>
<rect x="48" y="25" width="5" height="14" rx="2.5"/>
<rect x="56" y="30" width="4" height="4" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 619 B

@@ -0,0 +1,22 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck Waveform Symbol</title>
<desc id="desc">Golden segmented waveform symbol for StemDeck.</desc>
<defs>
<linearGradient id="gold" x1="110" y1="112" x2="402" y2="400" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.48" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<g fill="url(#gold)">
<rect x="110" y="248" width="18" height="16" rx="8"/>
<rect x="144" y="204" width="24" height="104" rx="12"/>
<rect x="184" y="154" width="26" height="204" rx="13"/>
<rect x="225" y="214" width="22" height="84" rx="11"/>
<rect x="267" y="104" width="28" height="304" rx="14"/>
<rect x="314" y="188" width="24" height="136" rx="12"/>
<rect x="354" y="152" width="26" height="208" rx="13"/>
<rect x="395" y="216" width="24" height="80" rx="12"/>
<rect x="434" y="248" width="18" height="16" rx="8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,6 @@
<svg width="820" height="180" viewBox="0 0 820 180" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title">
<title id="title">StemDeck Wordmark</title>
<text x="20" y="112" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F4F6F8">Stem</text>
<text x="268" y="112" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F2B53D">Deck</text>
<text x="23" y="158" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="24" font-weight="500" letter-spacing="8" fill="#7F8A94">POWERED STEM SEPARATION</text>
</svg>

After

Width:  |  Height:  |  Size: 683 B

+77
View File
@@ -0,0 +1,77 @@
StemDeck Linux Portable Alpha
=============================
This comes in two variants. Pick one:
- StemDeck-Linux-x64.tar.gz CPU-only (smaller; runs anywhere)
- StemDeck-Linux-x64.NVIDIA.tar.gz NVIDIA/CUDA (larger; much faster on an
NVIDIA GPU, falls back to CPU if no GPU)
Run
---
1. Extract the tarball, e.g.:
tar -xzf StemDeck-Linux-x64.tar.gz
2. Install the runtime prerequisites (see below).
3. Run the launcher:
cd StemDeck-Linux-x64 # or StemDeck-Linux-x64.NVIDIA
./StemDeck
4. Let first-run setup prepare local runtime assets.
Prerequisites
-------------
This portable package bundles its own Python runtime (torch + demucs), and
StemDeck downloads FFmpeg automatically on first launch (or uses a system
`ffmpeg` if one is already on your PATH). The only system libraries you need
are your distro's WebKitGTK + GTK, which the desktop shell links against.
Debian / Ubuntu:
sudo apt update
sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0
Fedora:
sudo dnf install webkit2gtk4.1 gtk3
Arch:
sudo pacman -S webkit2gtk-4.1 gtk3
NVIDIA variant
--------------
To use the GPU you need a working NVIDIA driver on the host such that
`nvidia-smi` runs and reports your GPU.
Check your driver:
nvidia-smi
On first launch, the NVIDIA build detects your GPU and downloads the matching
CUDA-enabled PyTorch (a few GB) into your data directory — so the first run
needs an internet connection and some disk space. You do NOT need a separate
CUDA toolkit install, only the driver.
If no usable GPU is detected, the NVIDIA build still runs and falls back to CPU.
If you do not have an NVIDIA GPU, use the CPU-only tarball instead — it skips
the CUDA download entirely.
Notes
-----
- This is a portable folder, not a system package. No .desktop entry, service,
or package-manager integration is created.
- User data lives under $XDG_DATA_HOME/stemdeck (or ~/.local/share/stemdeck).
- Your stem library is written to ~/Documents/StemDeck/.
- Demucs model weights download from the backend on first use into the data
directory under models/.
Troubleshooting
---------------
- "./StemDeck: error while loading shared libraries" — install the WebKitGTK
and GTK packages listed above.
- A job failing immediately with an FFmpeg error — first-run setup downloads
FFmpeg automatically; check internet access and retry, or install a system
`ffmpeg` so `ffmpeg -version` works in your shell.
- If setup fails, check internet access and retry.
- Inspect logs under the data directory's logs/ folder.
- Deleting the data directory forces first-run setup to recreate runtime state.
+78
View File
@@ -0,0 +1,78 @@
THIRD-PARTY NOTICES
===================
StemDeck includes third-party open-source software. Each component is
copyrighted by its respective authors and distributed under its own license.
This starter notice is not a substitute for the full license inventory that
must be generated from the final packaged Python runtime before a public
release.
Bundled Components
------------------
Python
License: Python Software Foundation License
Website: https://www.python.org/
Tauri
License: MIT or Apache-2.0, depending on component
Website: https://tauri.app/
FastAPI
License: MIT
Website: https://fastapi.tiangolo.com/
Uvicorn
License: BSD
Website: https://www.uvicorn.org/
yt-dlp
License: Unlicense
Website: https://github.com/yt-dlp/yt-dlp
Demucs
License: MIT
Website: https://github.com/facebookresearch/demucs
PyTorch / Torch
License: BSD-style
Website: https://pytorch.org/
torchaudio
License: BSD-style
Website: https://pytorch.org/audio/
librosa
License: ISC
Website: https://librosa.org/
pyloudnorm
License: MIT
Website: https://github.com/csteinmetz1/pyloudnorm
soundfile
License: BSD
Website: https://github.com/bastibe/python-soundfile
System Requirements (Not Bundled)
---------------------------------
FFmpeg is not bundled in the Linux package. On first launch StemDeck downloads a
static FFmpeg build into your user data directory (or uses a system `ffmpeg`
already on your PATH). The download is fetched directly from the upstream
provider, not redistributed by StemDeck. FFmpeg may be distributed under LGPL or
GPL terms depending on how the build was compiled.
WebKitGTK and GTK shared libraries are provided by your Linux distribution and
are not bundled. They are distributed under LGPL terms.
Demucs model weights are not bundled. They are downloaded during first use.
StemDeck should display the model source and license/usage terms before or
during download.
Disclaimer
----------
This notice file is not legal advice. Before public release, verify the exact
licenses of every bundled package and generated binary artifact.
+31
View File
@@ -0,0 +1,31 @@
StemDeck for macOS
==================
Install:
1. Open the StemDeck DMG.
2. Drag StemDeck.app to Applications.
3. Open StemDeck from Applications.
First launch:
- StemDeck is a thin native app. It downloads a pinned, checksummed StemDeck
runtime pack on first launch.
- The runtime installs to:
~/Library/Application Support/StemDeck/runtime
- FFmpeg and ffprobe install to:
~/Library/Application Support/StemDeck/ffmpeg
- Demucs model weights download on first use and are cached under:
~/Library/Application Support/StemDeck/models
Uninstall:
1. Delete /Applications/StemDeck.app.
2. To remove runtime files, jobs, caches, models, and logs, delete:
~/Library/Application Support/StemDeck
Notes:
- Internet access is required for first-run setup.
- Public releases should be signed and notarized.
- Unsigned local builds are for development and internal testing only.
+20
View File
@@ -0,0 +1,20 @@
THIRD-PARTY NOTICES
===================
StemDeck for macOS uses a thin Tauri app plus a downloaded runtime pack.
Bundled in StemDeck.app:
- Tauri v2
- The system WebKit WebView provided by macOS
Downloaded during first-run setup:
- StemDeck runtime pack containing Python and Python package dependencies
- FFmpeg and ffprobe
- Demucs model weights
The runtime pack includes its own dependency inventory under
runtime/licenses/pip-list.json. Before public release, generate and include
full license texts for every packaged dependency and verify the exact FFmpeg
build license/provenance.
+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="400" viewBox="0 0 660 400">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#15222a"/>
<stop offset="0.55" stop-color="#071017"/>
<stop offset="1" stop-color="#141c22"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="40%" r="55%">
<stop offset="0" stop-color="#f2b53f" stop-opacity="0.24"/>
<stop offset="0.48" stop-color="#2f8fb5" stop-opacity="0.10"/>
<stop offset="1" stop-color="#071017" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12"/>
</filter>
</defs>
<rect width="660" height="400" rx="0" fill="url(#bg)"/>
<rect width="660" height="400" fill="url(#glow)"/>
<g opacity="0.16" filter="url(#soft)">
<path d="M0 296 C 92 260, 146 328, 230 292 S 390 255, 474 296 S 602 335, 660 286" fill="none" stroke="#f0b33d" stroke-width="18"/>
<path d="M0 322 C 84 288, 156 350, 240 318 S 386 282, 478 320 S 594 352, 660 316" fill="none" stroke="#4aa7c7" stroke-width="12"/>
</g>
<g opacity="0.28">
<line x1="330" y1="122" x2="330" y2="242" stroke="#ffffff" stroke-width="1"/>
<path d="M300 196 L330 226 L360 196" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="330" y="322" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif" font-size="16" font-weight="600" fill="#dce5e8">Drag StemDeck to Applications</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+25
View File
@@ -0,0 +1,25 @@
StemDeck Windows Portable Alpha
===============================
Run
---
1. Extract the zip folder.
2. Double-click StemDeck.exe.
3. Let first-run setup prepare local runtime assets.
Notes
-----
- This is a portable folder, not an installer.
- No Start Menu shortcut, service, or registry integration is created.
- Generated files stay under data/.
- FFmpeg is downloaded during first-run setup into data/ffmpeg/.
- Demucs model weights are downloaded by the backend on first use into data/models/.
Troubleshooting
---------------
- If setup fails, check internet access and retry.
- If a job fails, inspect data/jobs/ and data/logs/ when logs are added.
- Deleting data/ forces first-run setup to recreate runtime state.
+74
View File
@@ -0,0 +1,74 @@
THIRD-PARTY NOTICES
===================
StemDeck includes third-party open-source software. Each component is
copyrighted by its respective authors and distributed under its own license.
This starter notice is not a substitute for the full license inventory that
must be generated from the final packaged Python runtime before a public
release.
Bundled Components
------------------
Python
License: Python Software Foundation License
Website: https://www.python.org/
Tauri
License: MIT or Apache-2.0, depending on component
Website: https://tauri.app/
FastAPI
License: MIT
Website: https://fastapi.tiangolo.com/
Uvicorn
License: BSD
Website: https://www.uvicorn.org/
yt-dlp
License: Unlicense
Website: https://github.com/yt-dlp/yt-dlp
Demucs
License: MIT
Website: https://github.com/facebookresearch/demucs
PyTorch / Torch
License: BSD-style
Website: https://pytorch.org/
torchaudio
License: BSD-style
Website: https://pytorch.org/audio/
librosa
License: ISC
Website: https://librosa.org/
pyloudnorm
License: MIT
Website: https://github.com/csteinmetz1/pyloudnorm
soundfile
License: BSD
Website: https://github.com/bastibe/python-soundfile
Downloaded During First-Run Setup
---------------------------------
FFmpeg binaries are not bundled in the StemDeck alpha zip. They are downloaded
during first-run setup. FFmpeg builds may be distributed under LGPL or GPL
terms depending on how they are compiled. StemDeck should display the selected
FFmpeg build source and license before or during download.
Demucs model weights are not bundled in the StemDeck alpha zip. They are
downloaded during first use. StemDeck should display the model source and
license/usage terms before or during download.
Disclaimer
----------
This notice file is not legal advice. Before public release, verify the exact
licenses of every bundled package and generated binary artifact.
+103
View File
@@ -0,0 +1,103 @@
[project]
name = "stemdeck"
# Version is derived from the git tag by hatch-vcs (see [tool.hatch.version]),
# so it is never hand-edited — `git tag vX.Y.Z` is the single source of truth.
# Builds between tags report a dev version (e.g. 0.7.0a5.dev3+g<sha>). (#169)
dynamic = ["version"]
description = "Paste a YouTube URL, get audio stems split into a DAW-style player."
requires-python = ">=3.10,<3.14"
dependencies = [
"fastapi>=0.115,!=0.136.3",
"uvicorn[standard]>=0.30",
"yt-dlp>=2024.12.0",
# Pin below 4.1: demucs 4.1.0 (2026-07-11) added a dependency on `sphn`, a
# Rust/CMake native package with no Intel-macOS wheel, so the x64 macOS
# build compiles it from source and fails (CMake dropped <3.5 support).
# 4.0.1 is what every prior release shipped and what the torch/torchaudio
# pins below were written for.
"demucs>=4.0.1,<4.1",
# Pin torch/torchaudio to 2.6.x where available — torchaudio 2.7+ removed its built-in
# audio writer and now requires the separate `torchcodec` package, which
# has ABI issues with torch 2.11. Demucs 4.0.1 calls torchaudio.save() to
# write stems, so it breaks on 2.7+.
"torch>=2.6,<2.7; sys_platform != 'darwin' or platform_machine != 'x86_64'",
"torchaudio>=2.6,<2.7; sys_platform != 'darwin' or platform_machine != 'x86_64'",
# PyTorch does not publish macOS x86_64 wheels for 2.6.x. Keep Intel macOS
# on the last compatible line so the x64 runtime pack can still be built.
"torch>=2.2,<2.3; sys_platform == 'darwin' and platform_machine == 'x86_64'",
"torchaudio>=2.2,<2.3; sys_platform == 'darwin' and platform_machine == 'x86_64'",
# torchaudio 2.6 dispatches WAV writes through libsndfile via soundfile.
# Without this, demucs crashes on save with "Couldn't find appropriate
# backend to handle uri ... drums.wav".
"soundfile>=0.12",
# BPM + key analysis on the downloaded source. Pulls numpy/scipy/numba.
"librosa>=0.10",
# Current llvmlite releases no longer publish macOS x86_64 wheels, which
# makes Intel runtime builds require a full LLVM/CMake toolchain. Keep Intel
# macOS on a wheel-backed Numba line.
"numba>=0.61,<0.62; sys_platform == 'darwin' and platform_machine == 'x86_64'",
# Torch 2.2's Intel macOS wheel was built against NumPy 1.x.
"numpy<2; sys_platform == 'darwin' and platform_machine == 'x86_64'",
# ITU-R BS.1770 integrated-loudness (LUFS) measurement for the
# post-analysis loudness card. Pure Python, depends on scipy which
# librosa already pulls in.
"pyloudnorm>=0.1.1",
# FastAPI multipart/form-data (file uploads) unconditionally require
# python-multipart — it is not an optional extra.
"python-multipart>=0.0.9",
# CVE-2026-44431 / CVE-2026-44432: sensitive header forwarding and
# decompression-bomb bypass fixed in 2.7.0. urllib3 is a transitive
# dep via requests; pin floor to pull in the fix.
"urllib3>=2.7.0",
# Pure-Python QR code generator used by the /api/qr endpoint (server
# access QR codes in the desktop settings panel).
"segno>=1.6",
]
[project.optional-dependencies]
dev = [
"ruff>=0.6",
"pytest>=8",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
# Derive the version from git tags. Writes app/_version.py at build time as a
# runtime fallback for environments without installed package metadata.
[tool.hatch.version]
source = "vcs"
# Used when the tag can't be reached (e.g. CI's shallow/tagless clone) so the
# build never hard-fails. Real builds derive from the tag or the pinned
# SETUPTOOLS_SCM_PRETEND_VERSION. (#169)
fallback-version = "0.0.0"
[tool.hatch.build.hooks.vcs]
version-file = "app/_version.py"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
target-version = "py310"
line-length = 100
# app/_version.py is generated by hatch-vcs at build/sync time — don't lint it.
exclude = ["jobs", ".run", "static/vendor", "app/_version.py"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
ignore = [
"E501", # line too long -- format-check is the gate, not lint
"B008", # FastAPI uses Depends(...) in defaults
"SIM105", # try/except/pass is fine; contextlib.suppress is heavier for one-shot warm-up imports
]
[tool.ruff.lint.isort]
split-on-trailing-comma = false
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
Executable
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# stemdeck dev server control: setup | start | stop | restart | status
set -euo pipefail
cd "$(dirname "$0")"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8000}"
RELOAD="${RELOAD:-0}"
# Treat the self-hosted server as a persistent, user-managed library (like the
# desktop app): opt out of the 24h job TTL sweep so processed tracks are not
# auto-deleted. Override with STEMDECK_PERSIST_LIBRARY=0 for disk-hygiene mode.
export STEMDECK_PERSIST_LIBRARY="${STEMDECK_PERSIST_LIBRARY:-1}"
FOREGROUND="${FOREGROUND:-0}"
PID_FILE=".run/uvicorn.pid"
LOG_FILE=".run/uvicorn.log"
UVICORN=".venv/bin/uvicorn"
mkdir -p .run
is_running() {
[[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}
start() {
if is_running; then
echo "already running (pid $(cat "$PID_FILE"))"
return 0
fi
if [[ ! -x "$UVICORN" ]]; then
echo "uvicorn not found at $UVICORN — run: uv sync" >&2
exit 1
fi
echo "starting on http://$HOST:$PORT"
local args=(app.main:app --host "$HOST" --port "$PORT")
if [[ "$RELOAD" == "1" ]]; then
args+=(--reload)
fi
if [[ "$FOREGROUND" == "1" ]]; then
echo $$ >"$PID_FILE"
exec "$UVICORN" "${args[@]}"
fi
nohup "$UVICORN" "${args[@]}" >"$LOG_FILE" 2>&1 &
echo $! >"$PID_FILE"
for _ in {1..10}; do
sleep 0.5
grep -q "Application startup complete\|Uvicorn running on" "$LOG_FILE" 2>/dev/null && break
is_running || break
done
if is_running; then
echo "started (pid $(cat "$PID_FILE"), log: $LOG_FILE)"
else
echo "failed to start — see $LOG_FILE" >&2
exit 1
fi
}
stop() {
if ! is_running; then
echo "not running"
# also sweep any stray uvicorn for this app
pkill -f "uvicorn app.main:app" 2>/dev/null || true
rm -f "$PID_FILE"
return 0
fi
local pid
pid=$(cat "$PID_FILE")
echo "stopping pid $pid"
kill "$pid" 2>/dev/null || true
for _ in {1..10}; do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
if kill -0 "$pid" 2>/dev/null; then
echo "force-killing pid $pid"
kill -9 "$pid" 2>/dev/null || true
fi
# kill any in-flight demucs children spawned by the app
pkill -f "python -m demucs" 2>/dev/null || true
rm -f "$PID_FILE"
echo "stopped"
}
status() {
if is_running; then
echo "running (pid $(cat "$PID_FILE")) on http://$HOST:$PORT"
else
echo "not running"
fi
}
setup() {
local os
case "$(uname -s)" in
Darwin) os="macos" ;;
Linux) os="linux" ;;
*)
echo "setup: unsupported OS '$(uname -s)' — install ffmpeg + uv manually" >&2
exit 1
;;
esac
echo "detected: $os"
if [[ "$os" == "macos" ]]; then
if ! command -v brew >/dev/null 2>&1; then
echo "setup: Homebrew not found — install from https://brew.sh first" >&2
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "==> brew install ffmpeg"
brew install ffmpeg
else
echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))"
fi
if ! command -v uv >/dev/null 2>&1; then
echo "==> brew install uv"
brew install uv
else
echo "uv: already installed ($(uv --version))"
fi
else
# linux: assume Debian/Ubuntu (apt). Other distros fall through to manual.
if ! command -v apt-get >/dev/null 2>&1; then
echo "setup: non-apt Linux detected — install ffmpeg + uv via your package manager" >&2
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "==> sudo apt-get update && sudo apt-get install -y ffmpeg"
sudo apt-get update
sudo apt-get install -y ffmpeg
else
echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))"
fi
if ! command -v uv >/dev/null 2>&1; then
echo "==> installing uv via astral.sh installer"
curl -LsSf https://astral.sh/uv/install.sh | sh
# installer drops uv in ~/.local/bin; surface it for this shell
export PATH="$HOME/.local/bin:$PATH"
else
echo "uv: already installed ($(uv --version))"
fi
fi
echo "==> uv sync"
uv sync --python 3.12
echo
echo "setup complete. start the server with: ./run.sh start"
}
case "${1:-}" in
setup) setup ;;
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*)
echo "usage: $0 {setup|start|stop|restart|status}" >&2
exit 2
;;
esac
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
#
# Build a portable Linux StemDeck package: a single .tar.gz containing the
# Tauri binary plus a self-contained Python runtime (torch + demucs), so the
# user extracts and runs ./StemDeck with no toolchain.
#
# This is the Linux analog of scripts/windows/make-portable.ps1. Like the macOS
# runtime pack (scripts/macos/make-runtime-pack.sh) it bundles a full
# python-build-standalone install — a plain `venv` will not work because the
# desktop shell checks for the stdlib under python/lib/ (python_stdlib_present
# in desktop/src-tauri/src/main.rs).
#
# Phase 1 ships the CPU-only variant. FFmpeg is NOT bundled in the tarball (so we
# don't redistribute it); instead the desktop shell downloads a static build on
# first launch into the user data dir, falling back to a system `ffmpeg` on PATH
# when one exists (see ensure_ffmpeg / download_linux_ffmpeg).
#
# Layout produced (so find_repo_root matches its backend/app + python branch):
# StemDeck-Linux-x64/
# StemDeck # Tauri ELF binary
# cpu-only # marker read by is_cpu_only_package
# README-LINUX.txt
# THIRD_PARTY_NOTICES.txt
# backend/{app,static,pyproject.toml,uv.lock}
# python/{bin/python,lib/pythonX.Y/...} # full PBS install
set -euo pipefail
PACKAGE_NAME="${PACKAGE_NAME:-StemDeck-Linux-x64}"
PACKAGE_VERSION="${PACKAGE_VERSION:-}"
OUTPUT_ROOT="${OUTPUT_ROOT:-dist}"
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
TORCH_VERSION="${TORCH_VERSION:-2.6.0}"
SKIP_TAURI_BUILD="${SKIP_TAURI_BUILD:-0}"
# CPU_ONLY=1 (default): force the CPU-only torch wheel and mark the package so the
# desktop shell skips GPU detection. CPU_ONLY=0: keep the project's default torch,
# which on Linux x86_64 is the CUDA build (NVIDIA variant) — the shell then detects
# the GPU and uses CUDA at runtime.
CPU_ONLY="${CPU_ONLY:-1}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
STAGE="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}"
ARCHIVE_PATH="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}.tar.gz"
CHECKSUM_PATH="${ARCHIVE_PATH}.sha256"
PYTHON_DIR="${STAGE}/python"
BACKEND_DIR="${STAGE}/backend"
TARGET_BIN="${REPO_ROOT}/desktop/src-tauri/target/release/stemdeck"
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: this packaging script must run on Linux." >&2
exit 1
fi
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $1" >&2
exit 1
fi
}
require_command uv
require_command cargo
require_command node
require_command npm
require_command tar
require_command sha256sum
# python-build-standalone (PBS) Python for x86_64 Linux. Unlike a venv, the
# full install carries its own stdlib under lib/, which the desktop shell needs.
echo "==> Installing python-build-standalone ${PYTHON_VERSION}"
uv python install "cpython-${PYTHON_VERSION}-linux-x86_64-gnu"
PBS_PYTHON="$(uv python find "cpython-${PYTHON_VERSION}-linux-x86_64-gnu")"
PBS_BASE_PREFIX="$("$PBS_PYTHON" -c 'import sys; print(sys.base_prefix)')"
if [[ ! -d "${PBS_BASE_PREFIX}/lib" ]]; then
echo "ERROR: PBS base prefix has no lib/ dir: ${PBS_BASE_PREFIX}" >&2
exit 1
fi
echo "==> Cleaning stage"
rm -rf "$STAGE" "$ARCHIVE_PATH" "$CHECKSUM_PATH"
mkdir -p "$STAGE" "$BACKEND_DIR" "$PYTHON_DIR"
# Copy the entire PBS install into python/ (-a preserves symlinks/permissions).
echo "==> Bundling Python runtime from ${PBS_BASE_PREFIX}"
cp -a "$PBS_BASE_PREFIX/." "$PYTHON_DIR/"
# PBS ships an EXTERNALLY-MANAGED marker that blocks installs into the copy.
find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete 2>/dev/null || true
BUNDLED_PYTHON="${PYTHON_DIR}/bin/python"
echo "==> Installing StemDeck into bundled Python"
# --system is required because python/ is a full PBS install, not a venv.
uv pip install --system --python "$BUNDLED_PYTHON" pip setuptools wheel
# Version is git-derived (hatch-vcs / setuptools-scm). Pin it so the install
# does not depend on git tags in the build checkout (#169).
if [[ -n "$PACKAGE_VERSION" ]]; then
export SETUPTOOLS_SCM_PRETEND_VERSION="${PACKAGE_VERSION#v}"
fi
uv pip install --system --python "$BUNDLED_PYTHON" "$REPO_ROOT"
# Always bake the small CPU-only torch wheel — for BOTH variants. On Linux the
# default PyPI torch wheel bundles the full CUDA runtime (~2.5 GB), which makes
# the packaged tarball exceed GitHub's 2 GiB per-asset release limit. So we
# mirror what the Windows NVIDIA package actually does: ship CPU torch, and let
# the desktop shell download the matching CUDA wheel at first run on GPU
# machines (install_cuda_torch, gated cfg(not(macos)) so it covers Linux). The
# NVIDIA variant differs only by omitting the cpu-only marker below.
#
# pip strips the local '+cpu' version when resolving, so the project install
# pulls the CUDA wheel even if a CPU wheel was requested; --force-reinstall
# --no-deps replaces just the torch/torchaudio wheels (proven on Windows).
echo "==> Baking CPU-only torch (NVIDIA variant downloads CUDA at first run)"
"$BUNDLED_PYTHON" -m pip install \
"torch==${TORCH_VERSION}+cpu" "torchaudio==${TORCH_VERSION}+cpu" \
--index-url https://download.pytorch.org/whl/cpu \
--force-reinstall --no-deps
# The project install above pulled the default Linux torch, which is the CUDA
# build, dragging in nvidia-* CUDA runtime packages (cuDNN, cuBLAS, NCCL, ...) and
# triton -- together ~2.5 GB. The CPU torch swap used --no-deps, so those packages
# are now orphaned but still installed, bloating the tarball past GitHub's 2 GiB
# asset limit. Remove them: CPU torch does not use them, and the NVIDIA variant
# re-downloads CUDA at first run anyway.
echo "==> Removing orphaned CUDA runtime packages"
orphans=$("$BUNDLED_PYTHON" -m pip list --format=freeze 2>/dev/null \
| sed -n 's/^\(nvidia-[^=]*\)==.*/\1/p')
orphans="$orphans triton"
echo " removing:$orphans"
"$BUNDLED_PYTHON" -m pip uninstall -y $orphans 2>/dev/null || true
echo "==> Verifying imports"
"$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile; print('torch', torch.__version__, 'cuda', torch.version.cuda)"
echo "==> Staging backend"
cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app"
cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static"
cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml"
cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock"
RESOLVED_VERSION="${PACKAGE_VERSION#v}"
printf '{ "version": "%s" }\n' "$RESOLVED_VERSION" > "$BACKEND_DIR/static/version.json"
cp "$REPO_ROOT/packaging/linux/README-LINUX.txt" "$STAGE/README-LINUX.txt"
cp "$REPO_ROOT/packaging/linux/THIRD_PARTY_NOTICES.txt" "$STAGE/THIRD_PARTY_NOTICES.txt"
# CPU-only marker: read by is_cpu_only_package so the shell skips GPU detection.
# Omitted for the NVIDIA variant so the shell detects the GPU and uses CUDA.
if [[ "$CPU_ONLY" == "1" ]]; then
touch "$STAGE/cpu-only"
fi
echo "==> Stripping build-time artifacts from bundled Python"
find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true
TORCH_LIB="${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages/torch"
for rel in include test share/cmake; do
rm -rf "${TORCH_LIB:?}/${rel}" 2>/dev/null || true
done
# Static link archives are only needed to build C++ extensions, never to run.
find "$TORCH_LIB" -name "*.a" -type f -delete 2>/dev/null || true
echo "==> Building Tauri desktop binary"
if [[ "$SKIP_TAURI_BUILD" != "1" ]]; then
pushd "$REPO_ROOT/desktop" >/dev/null
if [[ -f package-lock.json ]]; then
npm ci --include=dev
else
npm install --include=dev
fi
CI=true node node_modules/@tauri-apps/cli/tauri.js build
popd >/dev/null
fi
if [[ ! -f "$TARGET_BIN" ]]; then
echo "ERROR: Tauri binary not found at ${TARGET_BIN}" >&2
exit 1
fi
cp "$TARGET_BIN" "$STAGE/StemDeck"
chmod +x "$STAGE/StemDeck"
echo "==> Creating archive"
tar -czf "$ARCHIVE_PATH" -C "${REPO_ROOT}/${OUTPUT_ROOT}" "$PACKAGE_NAME"
( cd "${REPO_ROOT}/${OUTPUT_ROOT}" && sha256sum "${PACKAGE_NAME}.tar.gz" > "${PACKAGE_NAME}.tar.gz.sha256" )
echo "==> Done"
if [[ "$CPU_ONLY" == "1" ]]; then
echo "Variant : CPU-only"
else
echo "Variant : NVIDIA/CUDA"
fi
echo "Stage : ${STAGE}"
echo "Archive : ${ARCHIVE_PATH}"
echo "Checksum: ${CHECKSUM_PATH}"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Default the version to the current git tag so local builds match the tag with
# no VERSION passed; falls back to a dev label outside a tagged checkout. (#169)
VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo 0.0.0-dev)}"
VERSION="${VERSION#v}"
BUILD_DIR="${REPO_ROOT}/.build"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-app.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
for cmd in node npm cargo; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
mkdir -p "$BUILD_DIR"
echo "==> Stamping version ${VERSION}"
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/Cargo.toml"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/tauri.conf.json"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/package.json"
cd "$REPO_ROOT/desktop"
# Tauri CLI reads CI env var as a boolean flag; Woodpecker sets CI=woodpecker
# which fails the boolean parser. Force a valid value.
export CI=true
npm ci
if [[ "$ARCH" == "arm64" ]]; then
npx tauri build --bundles app --target aarch64-apple-darwin
TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/aarch64-apple-darwin/release"
else
npx tauri build --bundles app --target x86_64-apple-darwin
TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/x86_64-apple-darwin/release"
fi
APP_DIR="${TARGET_DIR}/bundle/macos/StemDeck.app"
if [[ ! -d "$APP_DIR" ]]; then
APP_DIR="$(find "$REPO_ROOT/desktop/src-tauri/target" -path '*/bundle/macos/StemDeck.app' -type d | head -1)"
fi
if [[ -z "$APP_DIR" || ! -d "$APP_DIR" ]]; then
echo "ERROR: could not find built StemDeck.app" >&2
exit 1
fi
RESOURCES="${APP_DIR}/Contents/Resources"
mkdir -p "$RESOURCES"
if [[ -f "$BUILD_DIR/runtime-manifest-${ARCH}.json" ]]; then
cp "$BUILD_DIR/runtime-manifest-${ARCH}.json" "$RESOURCES/runtime-manifest.json"
else
cp "$REPO_ROOT/desktop/ui/runtime-manifest.json" "$RESOURCES/runtime-manifest.json"
fi
if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$RESOURCES/THIRD_PARTY_NOTICES.txt"
fi
echo "$APP_DIR" > "$BUILD_DIR/app-path-${ARCH}.txt"
echo "==> App ready: $APP_DIR"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
VERSION="${VERSION:-LOCAL_DEV_TEST}"
VERSION="${VERSION#v}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${REPO_ROOT}/.build"
DIST_DIR="${BUILD_DIR}/macos-dist"
DMG_STAGING="${BUILD_DIR}/dmg-staging-${ARCH}"
DMG_NAME="StemDeck-macOS-${ARCH}.dmg"
DMG_PATH="${DIST_DIR}/${DMG_NAME}"
DMG_RW_PATH="${DIST_DIR}/StemDeck-macOS-${ARCH}.rw.dmg"
RUNTIME_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst"
RUNTIME_PATH="${BUILD_DIR}/${RUNTIME_NAME}"
BACKGROUND_SRC="${REPO_ROOT}/packaging/macos/dmg-background.svg"
BACKGROUND_DIR_NAME=".background"
BACKGROUND_PNG_NAME="dmg-background.png"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-dmg.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
for cmd in ditto hdiutil qlmanage shasum; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
if [[ "$ARCH" == "arm64" ]]; then
APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app"
else
APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app"
fi
if [[ ! -d "$APP_DIR" ]]; then
echo "ERROR: built app not found: $APP_DIR" >&2
echo "Run: ARCH=${ARCH} scripts/macos/make-app.sh" >&2
exit 1
fi
if [[ ! -f "$RUNTIME_PATH" ]]; then
echo "ERROR: runtime pack not found: $RUNTIME_PATH" >&2
echo "Run: ARCH=${ARCH} VERSION=${VERSION} scripts/macos/make-runtime-pack.sh" >&2
exit 1
fi
rm -rf "$DMG_STAGING"
mkdir -p "$DMG_STAGING" "$DIST_DIR"
mkdir -p "$DMG_STAGING/$BACKGROUND_DIR_NAME"
ditto "$APP_DIR" "$DMG_STAGING/StemDeck.app"
ln -s /Applications "$DMG_STAGING/Applications"
if [[ -f "$BACKGROUND_SRC" ]]; then
qlmanage -t -s 1320 -o "$DMG_STAGING/$BACKGROUND_DIR_NAME" "$BACKGROUND_SRC" >/dev/null 2>&1
mv "$DMG_STAGING/$BACKGROUND_DIR_NAME/dmg-background.svg.png" "$DMG_STAGING/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME"
fi
if [[ -f "$REPO_ROOT/packaging/macos/README-macOS.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/README-macOS.txt" "$DMG_STAGING/README-macOS.txt"
fi
if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then
cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$DMG_STAGING/THIRD_PARTY_NOTICES.txt"
fi
rm -f "$DMG_PATH" "$DMG_RW_PATH"
hdiutil create \
-volname "StemDeck" \
-srcfolder "$DMG_STAGING" \
-ov \
-format UDRW \
"$DMG_RW_PATH"
MOUNT_DIR="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)"
cleanup_mount() {
hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
}
trap cleanup_mount EXIT
hdiutil attach "$DMG_RW_PATH" -readwrite -noverify -nobrowse -mountpoint "$MOUNT_DIR" >/dev/null
if command -v SetFile >/dev/null 2>&1; then
SetFile -a V "$MOUNT_DIR/$BACKGROUND_DIR_NAME" || true
SetFile -a V "$MOUNT_DIR/README-macOS.txt" || true
SetFile -a V "$MOUNT_DIR/THIRD_PARTY_NOTICES.txt" || true
fi
if [[ -f "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" ]]; then
osascript <<APPLESCRIPT || echo "warning: DMG window styling failed (cosmetic only, DMG is still valid)"
tell application "Finder"
set dmgFolder to POSIX file "$MOUNT_DIR" as alias
open dmgFolder
set current view of container window of dmgFolder to icon view
set toolbar visible of container window of dmgFolder to false
set statusbar visible of container window of dmgFolder to false
set bounds of container window of dmgFolder to {100, 100, 760, 500}
set viewOptions to icon view options of container window of dmgFolder
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 104
set text size of viewOptions to 13
set background picture of viewOptions to POSIX file "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME"
set position of item "StemDeck.app" of dmgFolder to {205, 205}
set position of item "Applications" of dmgFolder to {455, 205}
close container window of dmgFolder
open dmgFolder
update dmgFolder without registering applications
delay 1
close container window of dmgFolder
end tell
APPLESCRIPT
fi
sync
hdiutil detach "$MOUNT_DIR" >/dev/null
rmdir "$MOUNT_DIR"
trap - EXIT
hdiutil convert "$DMG_RW_PATH" -format UDZO -imagekey zlib-level=9 -o "$DMG_PATH" >/dev/null
rm -f "$DMG_RW_PATH"
CHECKSUMS_PATH="${DIST_DIR}/SHA256SUMS-macOS-${ARCH}.txt"
{
shasum -a 256 "$DMG_PATH"
shasum -a 256 "$RUNTIME_PATH"
} | sed "s#${REPO_ROOT}/##" > "$CHECKSUMS_PATH"
echo "==> DMG ready: $DMG_PATH"
echo "==> Runtime pack: $RUNTIME_PATH"
echo "==> Checksums: $CHECKSUMS_PATH"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SOURCE_SVG="${SOURCE_SVG:-${REPO_ROOT}/imgs/stemdeck-svg-assets/stemdeck-icon.svg}"
ICON_DIR="${REPO_ROOT}/desktop/src-tauri/icons"
WORK_DIR="${TMPDIR:-/tmp}/stemdeck-iconset"
RENDER_DIR="${WORK_DIR}/render"
ICONSET_DIR="${WORK_DIR}/icon.iconset"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-iconset.sh must run on macOS" >&2
exit 1
fi
for cmd in qlmanage sips iconutil; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
if [[ ! -f "$SOURCE_SVG" ]]; then
echo "ERROR: source SVG not found: $SOURCE_SVG" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$RENDER_DIR" "$ICONSET_DIR" "$ICON_DIR"
qlmanage -t -s 1024 -o "$RENDER_DIR" "$SOURCE_SVG" >/dev/null
RENDERED_PNG="$(find "$RENDER_DIR" -maxdepth 1 -type f -name '*.png' | head -1)"
if [[ -z "$RENDERED_PNG" || ! -f "$RENDERED_PNG" ]]; then
echo "ERROR: qlmanage did not render a PNG from $SOURCE_SVG" >&2
exit 1
fi
cp "$RENDERED_PNG" "$ICON_DIR/icon.png"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" "$ICON_DIR/icon.png" \
--out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null
done
sips -z 32 32 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null
sips -z 64 64 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null
sips -z 256 256 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null
sips -z 512 512 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null
cp "$ICON_DIR/icon.png" "$ICONSET_DIR/icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o "$ICON_DIR/icon.icns"
echo "==> Icon PNG: $ICON_DIR/icon.png"
echo "==> Icon ICNS: $ICON_DIR/icon.icns"
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
set -euo pipefail
ARCH="${ARCH:-arm64}"
VERSION="${VERSION:-LOCAL_DEV_TEST}"
VERSION="${VERSION#v}"
RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://github.com/stemdeckapp/stemdeck/releases/download/v${VERSION}}"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${REPO_ROOT}/.build"
STAGING="${BUILD_DIR}/runtime-staging-${ARCH}"
RUNTIME_DIR="${STAGING}/runtime"
PYTHON_DIR="${RUNTIME_DIR}/python"
BACKEND_DIR="${RUNTIME_DIR}/backend"
if [[ "$(uname)" != "Darwin" ]]; then
echo "ERROR: make-runtime-pack.sh must run on macOS" >&2
exit 1
fi
if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then
echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2
exit 1
fi
PYTHON_BIN="${PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in python3.12 python3.11 python3.10 python3; do
if command -v "$candidate" >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
for cmd in ditto shasum tar "$PYTHON_BIN"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "ERROR: required command not found on PATH: $cmd" >&2
exit 1
fi
done
PYTHON_VERSION="$("$PYTHON_BIN" - <<'PY'
import sys
print(f"{sys.version_info.major}.{sys.version_info.minor}")
PY
)"
PYTHON_MACHINE="$("$PYTHON_BIN" - <<'PY'
import platform
print(platform.machine())
PY
)"
case "$PYTHON_VERSION" in
3.10|3.11|3.12|3.13) ;;
*)
echo "ERROR: ${PYTHON_BIN} is Python ${PYTHON_VERSION}; Torch 2.6 runtime builds require Python 3.10-3.13." >&2
echo "Set PYTHON_BIN=/path/to/python3.12 or PYTHON_BIN=/path/to/python3.11." >&2
exit 1
;;
esac
HOST_ARCH="$(uname -m)"
if [[ "$ARCH" == "arm64" && "$PYTHON_MACHINE" != "arm64" ]]; then
echo "ERROR: arm64 runtime requires an arm64 Python, got ${PYTHON_MACHINE}" >&2
exit 1
fi
if [[ "$ARCH" == "x64" && "$PYTHON_MACHINE" != "x86_64" ]]; then
echo "ERROR: x64 runtime requires an x86_64 Python, got ${PYTHON_MACHINE}" >&2
exit 1
fi
if [[ "$ARCH" == "x64" && "$HOST_ARCH" == "arm64" ]]; then
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "ERROR: x64 runtime on arm64 hosts requires Rosetta 2." >&2
exit 1
fi
fi
rm -rf "$STAGING"
mkdir -p "$PYTHON_DIR" "$BACKEND_DIR" "$BUILD_DIR"
echo "==> Bundling Python installation (${ARCH})"
echo "==> Python: $("$PYTHON_BIN" --version)"
echo "==> Python architecture: ${PYTHON_MACHINE}"
# Get the full PBS (python-build-standalone) installation root.
# This directory has the complete stdlib in lib/pythonX.Y/ — unlike a venv,
# which only creates site-packages/ and relies on the original base_prefix
# (a path that won't exist on user machines) for stdlib.
PYTHON_BASE_PREFIX="$("$PYTHON_BIN" - <<'PY'
import sys
print(sys.base_prefix)
PY
)"
echo "==> Python base prefix: ${PYTHON_BASE_PREFIX}"
if [[ ! -d "${PYTHON_BASE_PREFIX}/lib" ]]; then
echo "ERROR: Python base prefix has no lib/ dir: ${PYTHON_BASE_PREFIX}" >&2
echo " Make sure PYTHON_BIN points to a python-build-standalone (UV) Python." >&2
exit 1
fi
# Verify the stdlib is actually present in base_prefix before copying.
STDLIB_CHECK="$("$PYTHON_BIN" - <<'PY'
import sys, pathlib
ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
p = pathlib.Path(sys.base_prefix) / "lib" / ver / "encodings" / "__init__.py"
print("ok" if p.is_file() else f"missing:{p}")
PY
)"
if [[ "$STDLIB_CHECK" != "ok" ]]; then
echo "ERROR: stdlib not found in base_prefix (${STDLIB_CHECK})" >&2
echo " base_prefix: ${PYTHON_BASE_PREFIX}" >&2
exit 1
fi
# Copy the entire PBS Python installation into the runtime bundle.
# ditto preserves symlinks, HFS+ metadata, and extended attributes.
ditto "$PYTHON_BASE_PREFIX" "$PYTHON_DIR"
# PBS Python ships an EXTERNALLY-MANAGED marker that blocks uv from installing
# packages into it. Remove it so we can treat this copy as our own install.
find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete
echo "==> Installing packages into bundled Python"
# --system is required because $PYTHON_DIR is not a venv (it's a full Python install).
uv pip install --system --python "$PYTHON_DIR/bin/python" pip setuptools wheel
# The project version is git-derived (hatch-vcs). Pin it explicitly from $VERSION
# so the install doesn't depend on git tags being present in the build checkout (#169).
SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" \
uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT"
echo "==> Verifying stdlib and imports"
PYTHON_DIR="$PYTHON_DIR" PYTHONHOME="$PYTHON_DIR" "$PYTHON_DIR/bin/python" - <<'PY'
import importlib, os, pathlib, sys
ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
stdlib = pathlib.Path(os.environ["PYTHON_DIR"]) / "lib" / ver
if not (stdlib / "encodings" / "__init__.py").is_file():
print(f"ERROR: encodings not found in {stdlib}", file=sys.stderr)
sys.exit(1)
print(f" stdlib OK at {stdlib}")
packages = [
"fastapi", "uvicorn", "yt_dlp", "demucs", "torch", "torchaudio",
"librosa", "pyloudnorm", "soundfile",
]
for package in packages:
importlib.import_module(package)
print(f" OK {package}")
PY
echo "==> Staging backend"
cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app"
cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static"
cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml"
cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock"
cat > "$BACKEND_DIR/static/version.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}"
}
JSON
echo "==> Capturing dependency inventory"
mkdir -p "$RUNTIME_DIR/licenses"
uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json"
cat > "$RUNTIME_DIR/runtime-manifest.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}",
"createdBy": "scripts/macos/make-runtime-pack.sh"
}
JSON
echo "==> Stripping Python caches"
find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst"
ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}"
if command -v zstd >/dev/null 2>&1; then
tar --zstd -cf "$ARCHIVE_PATH" -C "$STAGING" runtime
else
ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.gz"
ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}"
tar -czf "$ARCHIVE_PATH" -C "$STAGING" runtime
fi
SIZE="$(stat -f%z "$ARCHIVE_PATH")"
SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')"
RUNTIME_URL="${RELEASE_BASE_URL}/${ARCHIVE_NAME}"
cat > "${BUILD_DIR}/runtime-manifest-${ARCH}.json" <<JSON
{
"version": "${VERSION}",
"arch": "${ARCH}",
"runtimeUrl": "${RUNTIME_URL}",
"runtimeSha256": "${SHA256}",
"runtimeSize": ${SIZE},
"archiveName": "${ARCHIVE_NAME}"
}
JSON
echo "==> Runtime pack ready"
echo "Archive: ${ARCHIVE_PATH}"
echo "Size: ${SIZE}"
echo "SHA256: ${SHA256}"
echo "Manifest: ${BUILD_DIR}/runtime-manifest-${ARCH}.json"
+264
View File
@@ -0,0 +1,264 @@
param(
[string]$Configuration = "release",
[string]$OutputRoot = "dist",
[string]$PackageName = "StemDeck-Windows-x64",
[string]$PackageVersion,
[switch]$SkipTauriBuild,
[switch]$CpuOnly,
[switch]$StripVenv
)
$ErrorActionPreference = "Stop"
$PSNativeCommandErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if ($env:OS -ne "Windows_NT") {
throw "This packaging script must run on Windows."
}
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$Stage = Join-Path $Root "$OutputRoot\$PackageName"
$ZipPath = Join-Path $Root "$OutputRoot\$PackageName.zip"
$ChecksumPath = "$ZipPath.sha256"
$PythonDir = Join-Path $Stage "python"
$PythonExe = Join-Path $PythonDir "Scripts\python.exe"
$BackendDir = Join-Path $Stage "backend"
$DesktopDir = Join-Path $Root "desktop"
$TauriDir = Join-Path $DesktopDir "src-tauri"
$TargetExe = Join-Path $TauriDir "target\$Configuration\stemdeck.exe"
function Require-Command([string]$Name) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
}
}
function Copy-Tree([string]$Source, [string]$Destination) {
if (Test-Path $Destination) {
Remove-Item -Recurse -Force $Destination
}
Copy-Item -Recurse -Force $Source $Destination
}
function Copy-TreeContents([string]$Source, [string]$Destination, [string[]]$ExcludeNames = @()) {
New-Item -ItemType Directory -Force $Destination | Out-Null
Get-ChildItem -LiteralPath $Source -Force |
Where-Object { $ExcludeNames -notcontains $_.Name } |
ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force
}
}
function Set-PyvenvValue([string]$ConfigPath, [string]$Key, [string]$Value) {
$content = Get-Content -LiteralPath $ConfigPath
$pattern = "^\s*$([regex]::Escape($Key))\s*="
$line = "$Key = $Value"
$found = $false
$updated = foreach ($entry in $content) {
if ($entry -match $pattern) {
$found = $true
$line
} else {
$entry
}
}
if (-not $found) {
$updated += $line
}
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllLines($ConfigPath, [string[]]$updated, $utf8NoBom)
}
function Get-PackageVersion {
if ($PackageVersion) {
return $PackageVersion.TrimStart("v")
}
$tauriConfig = Get-Content -LiteralPath (Join-Path $TauriDir "tauri.conf.json") -Raw |
ConvertFrom-Json
return [string]$tauriConfig.version
}
function Bundle-PythonRuntime([string]$VenvDir, [string]$VenvPython) {
$baseExecutable = (& $VenvPython -c "import sys; print(getattr(sys, '_base_executable', sys.executable))").Trim()
if (-not (Test-Path $baseExecutable)) {
throw "Could not locate base Python executable: $baseExecutable"
}
$baseHome = Split-Path -Parent $baseExecutable
$portableBaseHome = Join-Path $VenvDir "base"
$baseLib = Join-Path $baseHome "Lib"
$baseDlls = Join-Path $baseHome "DLLs"
if (-not (Test-Path $baseLib)) {
throw "Could not locate base Python standard library: $baseLib"
}
Write-Host "Bundling Python runtime from $baseHome..."
New-Item -ItemType Directory -Force $portableBaseHome | Out-Null
Copy-Item -Force $baseExecutable (Join-Path $portableBaseHome "python.exe")
$basePythonw = Join-Path $baseHome "pythonw.exe"
if (Test-Path $basePythonw) {
Copy-Item -Force $basePythonw (Join-Path $portableBaseHome "pythonw.exe")
}
Get-ChildItem -LiteralPath $baseHome -Filter "*.dll" -File -Force |
Copy-Item -Destination $portableBaseHome -Force
if (Test-Path $baseDlls) {
Copy-Tree $baseDlls (Join-Path $portableBaseHome "DLLs")
}
Copy-TreeContents $baseLib (Join-Path $portableBaseHome "Lib") @("site-packages")
$cfg = Join-Path $VenvDir "pyvenv.cfg"
Set-PyvenvValue $cfg "home" $portableBaseHome
Set-PyvenvValue $cfg "executable" (Join-Path $portableBaseHome "python.exe")
}
function Invoke-TauriBuild {
$TauriCli = Join-Path $DesktopDir "node_modules\@tauri-apps\cli\tauri.js"
if (-not (Test-Path $TauriCli)) {
throw "Tauri CLI not found at $TauriCli. npm install/ci may have omitted devDependencies."
}
& node $TauriCli build
}
function Assert-Fresh-TauriBuild {
if (-not (Test-Path $TargetExe)) {
throw "Tauri executable not found at $TargetExe. Remove -SkipTauriBuild or build the NVIDIA package first."
}
$exe = Get-Item $TargetExe
$newerSources = @(
Get-ChildItem -Path (Join-Path $DesktopDir "ui") -File -Recurse
Get-ChildItem -Path (Join-Path $TauriDir "src") -File -Recurse
Get-Item (Join-Path $TauriDir "Cargo.toml")
Get-Item (Join-Path $TauriDir "tauri.conf.json")
) | Where-Object { $_.LastWriteTimeUtc -gt $exe.LastWriteTimeUtc }
if ($newerSources.Count -gt 0) {
$list = ($newerSources | Select-Object -First 8 | ForEach-Object { " - $($_.FullName)" }) -join "`n"
throw @"
-SkipTauriBuild would package a stale StemDeck.exe.
The existing executable is older than desktop UI/Tauri source files:
$list
Remove -SkipTauriBuild or run the NVIDIA package build first so the CPU package reuses a fresh executable.
"@
}
}
Require-Command "node"
Require-Command "npm"
Require-Command "cargo"
if (-not (Get-Command "py" -ErrorAction SilentlyContinue) -and -not (Get-Command "python" -ErrorAction SilentlyContinue)) {
throw "Python launcher not found. Install Python 3.12 on the Windows build agent."
}
if (Test-Path $Stage) {
Remove-Item -Recurse -Force $Stage
}
if (Test-Path $ZipPath) {
Remove-Item -Force $ZipPath
}
if (Test-Path $ChecksumPath) {
Remove-Item -Force $ChecksumPath
}
New-Item -ItemType Directory -Force $Stage | Out-Null
New-Item -ItemType Directory -Force $BackendDir | Out-Null
New-Item -ItemType Directory -Force (Join-Path $Stage "data") | Out-Null
foreach ($Dir in @("cache", "downloads", "ffmpeg", "jobs", "logs", "models")) {
New-Item -ItemType Directory -Force (Join-Path $Stage "data\$Dir") | Out-Null
}
if ($CpuOnly) {
New-Item -ItemType File -Force (Join-Path $Stage "cpu-only") | Out-Null
New-Item -ItemType File -Force (Join-Path $Stage "data\cpu-only") | Out-Null
}
Copy-Tree (Join-Path $Root "app") (Join-Path $BackendDir "app")
Copy-Tree (Join-Path $Root "static") (Join-Path $BackendDir "static")
$PackageVersion = Get-PackageVersion
$VersionJson = @{ version = $PackageVersion } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path $BackendDir "static\version.json"), $VersionJson + "`n", $utf8NoBom)
Copy-Item -Force (Join-Path $Root "pyproject.toml") (Join-Path $BackendDir "pyproject.toml")
Copy-Item -Force (Join-Path $Root "uv.lock") (Join-Path $BackendDir "uv.lock")
Copy-Item -Force (Join-Path $Root "packaging\windows\README-WINDOWS.txt") (Join-Path $Stage "README-WINDOWS.txt")
Copy-Item -Force (Join-Path $Root "packaging\windows\THIRD_PARTY_NOTICES.txt") (Join-Path $Stage "THIRD_PARTY_NOTICES.txt")
if (Get-Command "py" -ErrorAction SilentlyContinue) {
& py -3.12 -m venv $PythonDir
} else {
& python -m venv $PythonDir
}
& $PythonExe -m pip install --upgrade pip
# The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so
# the install doesn't depend on git tags in the build checkout (#169).
if ($PackageVersion) {
$env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '')
}
& $PythonExe -m pip install "$Root"
if ($CpuOnly) {
# pip strips local version identifiers when resolving requirements, so it installs
# the CUDA wheel from PyPI even when we pre-install the CPU wheel. Force-reinstall
# after the fact: uninstalls CUDA torch and replaces it with the CPU-only variant.
& $PythonExe -m pip install torch==2.6.0+cpu torchaudio==2.6.0+cpu `
--index-url https://download.pytorch.org/whl/cpu `
--force-reinstall --no-deps
}
& $PythonExe -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile"
Bundle-PythonRuntime $PythonDir $PythonExe
& $PythonExe -c "import sys, fastapi, uvicorn; print('Portable Python:', sys.executable)"
if ($StripVenv) {
Write-Host "Stripping venv of build-time artifacts..."
Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force |
Remove-Item -Recurse -Force
foreach ($rel in @("torch\include", "torch\share\cmake", "torch\test")) {
$p = Join-Path $PythonDir "Lib\site-packages\$rel"
if (Test-Path $p) { Remove-Item -Recurse -Force $p }
}
# Remove C++ static link libraries from torch — needed only for building C++ extensions,
# never for running Python. dnnl.lib alone is ~623 MB.
Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages\torch") `
-Filter "*.lib" -Recurse -File -Force |
Remove-Item -Force
}
Push-Location $DesktopDir
try {
if (Test-Path "package-lock.json") {
npm ci --include=dev
} else {
npm install --include=dev
}
if (-not $SkipTauriBuild) {
$env:CI = "true" # Woodpecker sets CI=woodpecker; Tauri only accepts true/false
rustup default stable
Invoke-TauriBuild
} else {
Assert-Fresh-TauriBuild
}
} finally {
Pop-Location
}
if (-not (Test-Path $TargetExe)) {
throw "Tauri executable not found at $TargetExe"
}
Copy-Item -Force $TargetExe (Join-Path $Stage "StemDeck.exe")
Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force
$Hash = Get-FileHash -Algorithm SHA256 $ZipPath
Set-Content -Path $ChecksumPath -Value "$($Hash.Hash) $PackageName.zip"
$Variant = if ($CpuOnly) { "CPU-only" } else { "CUDA/GPU (NVIDIA)" }
Write-Host "Variant : $Variant"
Write-Host "Staged at : $Stage"
Write-Host "Zip created : $ZipPath"
Write-Host "Checksum : $ChecksumPath"
+443
View File
@@ -0,0 +1,443 @@
/* ───────────── Top bar ───────────── */
.app:not(.is-import) .appbar {
padding-top: 12px;
padding-bottom: 14px;
}
/* ─── Collapsed icon strip (double-click to toggle) ─── */
/* Expandable body wrapper — same grid-row trick as .widget-body */
.appbar-body {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
opacity: 1;
width: 100%;
min-height: 0;
transition: grid-template-rows 220ms ease, opacity 180ms ease;
}
.appbar-body-inner {
min-height: 0;
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
align-items: stretch;
gap: 22px;
}
/* Strip wrapper — collapsed by default */
.appbar-icon-strip {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
opacity: 0;
pointer-events: none;
min-height: 0; /* prevent flex parent from reserving space when row is 0fr */
transition: grid-template-rows 220ms ease, opacity 200ms ease 20ms;
}
.appbar-strip-inner {
min-height: 0;
display: flex;
align-items: center;
gap: 5px;
margin: 0 auto;
}
/* Collapsed state */
.app.appbar-collapsed .appbar-body {
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
height: 0; /* force flex child to zero — grid-template-rows alone isn't enough in a flex container */
}
.app.appbar-collapsed .appbar-icon-strip {
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
width: 100%;
height: 40px;
align-items: center;
}
.app.appbar-collapsed .appbar {
min-height: 52px;
height: 52px;
padding: 6px 14px;
justify-content: center;
align-items: center;
}
.app.appbar-collapsed .appbar-strip-inner {
height: 40px;
align-items: center;
}
.strip-sq {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--glass-line);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
transition: background 120ms, border-color 120ms, opacity 120ms;
}
.strip-sq:hover { background: rgba(255,255,255,0.08) !important; }
.strip-sq-process:hover { filter: brightness(1.1); }
.strip-sq-brand {
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
color: var(--ink);
background: rgba(255,255,255,0.05);
letter-spacing: -0.03em;
}
.strip-sq-url {
background: rgba(255,255,255,0.03);
color: var(--ink-faint);
margin-right: 3px;
}
.strip-sq-stems {
display: flex;
align-items: center;
gap: 5px;
}
.strip-sq-stem {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
transition: opacity 120ms, background 120ms;
}
.strip-sq-stem:hover { opacity: 0.85 !important; }
.strip-sq-stem.inactive { opacity: 0.2; }
.strip-sq-process {
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
color: #17130c;
border-color: transparent;
margin-left: 4px;
}
.strip-sq-process.loading {
cursor: progress;
filter: saturate(0.92);
}
.strip-sq-process.loading svg {
display: none;
}
.strip-sq-process.loading::before {
content: "";
width: 16px;
height: 16px;
flex: 0 0 16px;
border-radius: 50%;
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
animation: spin 0.85s linear infinite;
}
.brand-version {
font-size: 11px;
color: rgba(216,222,230,0.35);
margin-top: 3px;
display: flex;
align-items: center;
gap: 6px;
}
.brand-update-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(100, 200, 120, 0.10);
border: 1px solid rgba(100, 200, 120, 0.22);
color: #6dcc82;
font-size: 9px;
text-decoration: none;
}
.brand-update-chip:hover {
background: rgba(100, 200, 120, 0.2);
color: #88dfa0;
}
.appbar {
display: flex;
flex-direction: column;
padding: 20px 24px 22px;
border: 1px solid var(--glass-line);
border-radius: 16px;
background:
radial-gradient(circle at 82% -20%, rgba(216, 168, 74, 0.11), transparent 20rem),
linear-gradient(180deg, rgba(27, 39, 48, 0.76), rgba(13, 22, 30, 0.84));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
var(--shadow-panel);
transition: padding 220ms ease;
}
.brand-group {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
min-width: 0;
padding-right: 8px;
}
.brand-row {
display: flex;
align-items: center;
gap: 8px;
}
.brand {
margin: 0;
font-family: var(--font-mono);
font-weight: 700;
font-size: 24px;
line-height: 1;
color: var(--ink);
letter-spacing: -0.01em;
}
.brand-logo {
display: block;
width: 190px;
height: auto;
}
.beta-badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 5px;
border: 1px solid rgba(216, 168, 74, 0.65);
border-radius: 6px;
background: var(--gold-soft);
color: var(--gold-bright);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.brand-sub {
font-size: 13px;
color: var(--ink-dim);
white-space: nowrap;
}
.post-urlbar,
.post-process-btn {
display: none;
}
.appbar-form {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.appbar-import-panel {
min-width: 0;
}
.appbar-import-panel .import-input-row {
grid-template-columns: minmax(0, 1fr) clamp(140px, 14vw, 220px);
gap: clamp(12px, 2vw, 28px);
padding-bottom: 22px;
}
.appbar-import-panel .url-wrap {
min-height: 58px;
padding: 0 20px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 11px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
rgba(3, 9, 14, 0.72);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.035),
inset 0 -1px 0 rgba(0, 0, 0, 0.32),
0 0 0 0 rgba(216, 168, 74, 0);
transition:
border-color var(--t-base) var(--easing),
box-shadow var(--t-base) var(--easing),
background-color var(--t-base) var(--easing);
}
.appbar-import-panel .url-wrap:focus-within {
border-color: rgba(216, 168, 74, 0.56);
background-color: rgba(6, 13, 19, 0.86);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
inset 0 -1px 0 rgba(0, 0, 0, 0.34),
0 0 0 3px rgba(216, 168, 74, 0.08),
0 14px 34px rgba(0, 0, 0, 0.22);
}
.appbar-import-panel .url-prefix {
color: #cbd2dc;
opacity: 0.92;
}
.appbar-import-panel input[type="url"] {
font-size: clamp(15px, 0.95vw, 18px);
height: 100%;
color: #edf0f4;
caret-color: var(--gold-bright);
letter-spacing: 0;
}
.appbar-import-panel .btn-primary {
height: 58px;
min-width: 0;
width: 100%;
border-radius: 11px;
font-size: clamp(14px, 0.9vw, 16px);
}
.appbar-import-panel .stem-choice-row {
grid-template-columns: clamp(110px, 11vw, 156px) repeat(6, minmax(0, 1fr));
gap: clamp(8px, 1vw, 14px);
padding-top: 20px;
}
.appbar-import-panel .stem-choice-row > span {
font-size: clamp(12px, 0.8vw, 14px);
}
.appbar-import-panel .stem-choice {
min-height: 48px;
padding: 0 14px;
border-radius: 10px;
font-size: clamp(12px, 0.8vw, 14px);
background:
linear-gradient(180deg, color-mix(in srgb, currentColor 9%, transparent), transparent),
rgba(8, 15, 22, 0.42);
}
.url-wrap {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
cursor: text;
}
.url-prefix {
flex-shrink: 0;
color: #d7dbe2;
}
.appbar-form input[type="url"],
.appbar-import-panel input[type="url"],
.import-card input[type="url"] {
flex: 1;
background: transparent;
border: 0;
outline: 0;
color: var(--ink);
font-family: var(--font-mono);
font-size: 19px;
padding: 0;
min-width: 0;
appearance: none;
box-shadow: none;
}
.appbar-form input[type="url"]::placeholder,
.appbar-import-panel input[type="url"]::placeholder,
.import-card input[type="url"]::placeholder {
color: #9da3ad;
opacity: 0.78;
}
.appbar-import-panel input[type="url"]::selection {
background: rgba(216, 168, 74, 0.28);
color: #fff6de;
}
.appbar-import-panel input[type="url"] {
font-size: clamp(15px, 0.95vw, 18px);
color: #edf0f4;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 56px;
min-width: 150px;
background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
color: #17130c;
border: 0;
border-radius: var(--radius);
padding: 0 24px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 700;
cursor: pointer;
position: relative;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 16px 36px rgba(216, 168, 74, 0.18);
transition:
filter var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-primary:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary:disabled {
opacity: 0.82;
cursor: progress;
}
.btn-primary.loading {
filter: saturate(0.92);
}
.btn-primary.loading .icon {
display: none;
}
.btn-primary.loading::before {
content: "";
width: 18px;
height: 18px;
flex: 0 0 18px;
border-radius: 50%;
background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0);
animation: spin 0.85s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.btn-primary.loading::before {
animation-duration: 1.8s;
}
}
+110
View File
@@ -0,0 +1,110 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at 50% 0%, rgba(216, 168, 74, 0.1), transparent 34rem),
radial-gradient(circle at 0% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
radial-gradient(circle at 100% 45%, rgba(216, 168, 74, 0.055), transparent 28rem),
linear-gradient(180deg, #081016 0%, #050a0f 100%);
color: var(--ink);
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.hidden {
display: none !important;
}
:focus {
outline: 0;
}
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
.surface-panel {
border: 1px solid var(--glass-line);
background:
linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
var(--shadow-panel);
}
.app {
max-width: 1640px;
margin: 0 auto;
min-height: 100vh;
padding: 24px 28px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.app:not(.is-import) {
max-width: none;
width: 100%;
height: 100vh;
min-height: 0;
padding: 16px 29px 0;
gap: 8px;
overflow: hidden;
}
.app.is-import .now-playing,
.app.is-import #lanes {
display: none;
}
.app:not(.is-import) .import-page,
.app:not(.is-import) .site-footer {
display: none;
}
.site-footer {
display: flex;
align-items: center;
gap: 24px;
margin-top: auto;
padding: 18px 14px 22px;
color: #8d929a;
font-size: 14px;
}
.site-footer > span {
margin-right: auto;
}
.site-footer nav {
display: flex;
align-items: center;
gap: 26px;
}
.site-footer a {
color: inherit;
text-decoration: none;
}
.site-footer a:hover {
color: var(--ink);
}
.help-btn {
height: 42px;
padding: 0 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius);
background: rgba(18, 26, 34, 0.75);
color: var(--ink);
font: inherit;
}
+493
View File
@@ -0,0 +1,493 @@
/* ──────────────────────────────────────────────
catalog.css — Left catalog / history panel
────────────────────────────────────────────── */
/* ─── Panel shell ─── */
.catalog {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(28,39,48,0.82), rgba(15,23,31,0.86));
border: 1px solid var(--glass-line);
border-radius: 12px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel);
padding: 12px 10px;
gap: 10px;
height: 100%;
min-height: 0;
position: relative;
top: 0;
overflow: hidden;
transition: padding 220ms ease;
}
.app.cat-collapsed .catalog { padding: 12px 8px; }
/* ─── Header (click to collapse) ─── */
.catalog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 10px;
border-bottom: 1px solid rgba(157,170,182,0.1);
cursor: pointer;
user-select: none;
border-radius: 6px;
transition: background 120ms;
flex-shrink: 0;
}
.catalog-head:hover { background: rgba(31,43,52,0.55); }
.catalog-head h3 {
margin: 0;
font-family: var(--font-mono);
font-size: 17px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
}
.catalog-head .cat-count {
color: var(--ink-faint);
font-size: 11px;
flex: 1;
padding-left: 8px;
white-space: nowrap;
}
.cat-chevron {
width: 15px;
height: 15px;
color: var(--ink-faint);
transition: transform 220ms ease;
flex-shrink: 0;
}
/* Hide text content when collapsed */
.app.cat-collapsed .catalog-head h3,
.app.cat-collapsed .catalog-head .cat-count { display: none; }
.app.cat-collapsed .catalog-head { justify-content: center; padding: 10px 0; }
.app.cat-collapsed .cat-chevron { transform: rotate(180deg); }
/* ─── Search ─── */
.catalog-search {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(21,31,39,0.6);
color: var(--ink-faint);
font-size: 12px;
flex-shrink: 0;
}
.catalog-search svg { flex-shrink: 0; }
.catalog-search input {
min-width: 0;
width: 100%;
border: 0;
background: transparent;
color: var(--ink);
font: inherit;
font-size: 12px;
outline: none;
padding: 0;
}
.catalog-search input::placeholder {
color: var(--ink-faint);
}
/* ─── New folder button ─── */
.new-folder-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: transparent;
color: var(--ink-dim);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 120ms;
width: 100%;
flex-shrink: 0;
}
.new-folder-btn:hover {
border-color: var(--gold-line);
color: var(--gold);
}
.new-folder-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
/* ─── Catalog list ─── */
.catalog-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
flex: 1;
padding-right: 2px;
}
/* ─── Folder ─── */
.folder { display: flex; flex-direction: column; }
.folder-head {
--folder-color: var(--gold);
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
color: var(--ink-dim);
transition: background 120ms, color 120ms;
user-select: none;
}
.folder-head:hover { background: rgba(31,43,52,0.55); color: var(--ink); }
.folder.drop-target .folder-head {
background: rgba(216,168,74,0.10);
box-shadow: inset 0 0 0 1px var(--gold-line);
color: var(--ink);
}
.folder-head .f-chevron {
width: 11px; height: 11px;
transition: transform 180ms ease;
flex-shrink: 0;
color: var(--ink-faint);
}
.folder.collapsed .folder-head .f-chevron { transform: rotate(-90deg); }
.folder-head .f-icon {
width: 13px;
height: 13px;
flex-shrink: 0;
color: var(--folder-color);
}
.folder-head .f-name {
font-size: 12px;
font-weight: 500;
color: inherit;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: transparent;
border: none;
padding: 0;
font-family: inherit;
outline: none;
cursor: inherit;
}
.folder-head .f-name[contenteditable="true"] {
background: rgba(28,34,43,0.8);
padding: 1px 4px;
border-radius: 3px;
cursor: text;
}
.folder-head .f-count { font-size: 10.5px; color: var(--ink-faint); }
.folder-color-dot {
width: 13px;
height: 13px;
border: 1px solid rgba(255,255,255,0.22);
border-radius: 999px;
background: var(--folder-color);
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.22);
cursor: pointer;
padding: 0;
}
.folder-color-dot:hover,
.folder-color-dot:focus-visible {
border-color: rgba(255,255,255,0.82);
outline: none;
}
.folder-color-dot.active {
box-shadow: 0 0 0 2px rgba(255,255,255,0.18), inset 0 0 0 1px rgba(0,0,0,0.24);
border-color: rgba(255,255,255,0.86);
}
.folder-head .f-del {
width: 18px; height: 18px;
border: none; background: transparent;
color: var(--ink-faint);
cursor: pointer;
opacity: 0;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
padding: 0;
}
.folder-head:hover .f-del { opacity: 1; }
.folder-head .f-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); }
.folder-editor-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
background: rgba(3, 8, 13, 0.42);
backdrop-filter: blur(2px);
}
.folder-editor {
width: min(300px, calc(100vw - 32px));
border: 1px solid var(--glass-line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98));
box-shadow: var(--shadow-panel);
padding: 12px;
color: var(--ink);
font-family: var(--font-mono);
}
.folder-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
}
.folder-editor-close {
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.folder-editor-close:hover {
background: rgba(21,31,39,0.8);
color: var(--ink);
}
.folder-editor-field {
display: grid;
gap: 7px;
margin-bottom: 12px;
color: var(--ink-faint);
font-size: 10.5px;
}
.folder-editor-name {
width: 100%;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(10, 17, 24, 0.78);
color: var(--ink);
font: inherit;
font-size: 12px;
padding: 0 10px;
outline: none;
}
.folder-editor-name:focus {
border-color: var(--gold-line);
box-shadow: 0 0 0 2px rgba(216,168,74,0.12);
}
.folder-editor-colors {
display: flex;
align-items: center;
gap: 9px;
}
.folder-editor-colors .folder-color-dot {
width: 22px;
height: 22px;
}
.folder-editor-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.folder-editor-actions button {
min-height: 30px;
border-radius: 7px;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
padding: 0 11px;
}
.folder-editor-cancel {
border: 1px solid var(--line);
background: rgba(21,31,39,0.52);
color: var(--ink-dim);
}
.folder-editor-save {
border: 1px solid var(--gold-line);
background: rgba(216,168,74,0.16);
color: var(--gold);
}
.folder-body {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px 0 3px 14px;
margin-left: 7px;
border-left: 1px solid rgba(157,170,182,0.1);
}
.folder.collapsed .folder-body { display: none; }
.folder-empty {
color: var(--ink-faint);
font-size: 11px;
padding: 5px 8px;
font-style: italic;
}
/* ─── Track item ─── */
.cat-item {
display: flex;
gap: 9px;
align-items: center;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: 9px;
cursor: pointer;
transition: background 120ms, border-color 120ms;
}
.cat-item:hover { background: rgba(31,43,52,0.55); border-color: var(--line); }
.cat-item.active { background: rgba(31,43,52,0.55); border-color: var(--gold-line); }
.cat-item.dragging { opacity: 0.4; }
.cat-item[draggable="true"] { cursor: grab; }
.cat-thumb {
width: 44px; height: 44px;
border-radius: 7px;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--line);
background: rgba(21,31,39,0.8);
}
.cat-thumb svg,
.cat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.cat-title {
color: var(--ink);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat-sub {
color: var(--ink-faint);
font-size: 10.5px;
display: flex;
gap: 5px;
white-space: nowrap;
overflow: hidden;
}
.cat-sub .dot { opacity: 0.4; }
.cat-status {
width: 7px; height: 7px;
border-radius: 50%;
background: #5fbc56;
flex-shrink: 0;
}
.cat-status.processing {
background: var(--gold);
animation: cat-pulse 1.4s infinite;
}
@keyframes cat-pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
.cat-del {
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--ink-faint);
cursor: pointer;
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
.cat-item:hover .cat-del,
.cat-del:focus-visible {
opacity: 1;
}
.cat-del:hover {
background: rgba(21,31,39,0.8);
color: var(--danger);
}
/* ─── Catalog footer ─── */
.catalog-foot {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 6px 0;
border-top: 1px solid rgba(157,170,182,0.08);
color: var(--ink-faint);
font-size: 11px;
flex-shrink: 0;
}
.catalog-foot svg { flex-shrink: 0; }
/* ─── Collapsed strip: just album thumbs ─── */
.cat-collapsed-strip {
display: none;
flex-direction: column;
gap: 8px;
align-items: center;
padding-top: 4px;
overflow-y: auto;
flex: 1;
}
.app.cat-collapsed .cat-collapsed-strip { display: flex; }
.app.cat-collapsed .catalog-search,
.app.cat-collapsed .new-folder-btn,
.app.cat-collapsed .catalog-list,
.app.cat-collapsed .catalog-foot { display: none; }
.strip-thumb {
width: 40px; height: 40px;
border-radius: 7px;
overflow: hidden;
border: 1px solid var(--line);
cursor: pointer;
transition: border-color 120ms, transform 120ms;
flex-shrink: 0;
background: rgba(21,31,39,0.8);
color: var(--ink-dim);
}
.strip-thumb:hover { transform: scale(1.06); }
.strip-thumb.active {
border-color: var(--gold-line);
box-shadow: 0 0 0 2px rgba(216,168,74,0.18);
}
.strip-thumb.folder-thumb {
color: var(--folder-color, var(--gold));
}
.strip-thumb.trash-thumb {
color: var(--ink-faint);
}
.strip-thumb svg,
.strip-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
.strip-thumb.folder-thumb svg,
.strip-thumb.trash-thumb svg {
width: 22px;
height: 22px;
margin: 8px auto;
}
+2424
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
/* ───────────── Job progress ───────────── */
.job {
display: none !important;
background: var(--panel);
border: var(--border-w) solid var(--line);
border-radius: var(--radius);
padding: 14px 16px;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
#job-title {
font-size: 14px;
flex: 1;
word-break: break-word;
}
#job-stage {
font-size: 13px;
color: var(--ink-dim);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
progress {
width: 100%;
height: 4px;
appearance: none;
background: var(--line);
border: 0;
border-radius: 2px;
overflow: hidden;
}
progress::-webkit-progress-bar {
background: var(--line);
}
progress::-webkit-progress-value {
background: var(--ink);
transition: width var(--t-base) var(--easing);
}
progress::-moz-progress-bar {
background: var(--ink);
}
/* Compact technical detail line under the progress bar -- shows the
truthful backend `stage` value while #job-stage rotates phrases. */
.job-detail {
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-dim);
opacity: 0.65;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cancel-btn {
flex-shrink: 0;
background: transparent;
color: var(--ink-dim);
border: var(--border-w) solid var(--line);
border-radius: var(--radius-xs);
padding: 4px 10px;
font: inherit;
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing);
}
.cancel-btn:hover:not(:disabled) {
background: var(--danger);
color: var(--bg);
border-color: var(--danger);
}
.cancel-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ───────────── Error ───────────── */
.error {
background: rgba(214, 90, 74, 0.1);
border: var(--border-w) solid rgba(214, 90, 74, 0.5);
color: var(--danger);
border-radius: var(--radius);
padding: 12px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.error-msg {
flex: 1;
word-break: break-word;
font-size: 13px;
}
.retry-btn {
flex-shrink: 0;
background: transparent;
color: var(--danger);
border: var(--border-w) solid var(--danger);
border-radius: var(--radius-xs);
padding: 5px 12px;
font: inherit;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing);
}
.retry-btn:hover {
background: var(--danger);
color: var(--bg);
}
+106
View File
@@ -0,0 +1,106 @@
/* ───────────── Master fader + VU ─────────────
* Console-style: tick-marked vertical fader on the left, twin segmented
* LED VU meters on the right, both inside a recessed dark panel.
* Custom rendering throughout — `appearance: none` so the browser
* doesn't fall back to its blue-track default vertical slider, which
* ignores ::-webkit-slider-thumb overrides and offsets the thumb. */
.np-master {
display: flex;
align-items: stretch;
gap: 10px;
height: 140px;
padding: 8px 10px;
background: linear-gradient(180deg, #141414, #0d0d0d);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4);
}
/* Fader column with tick marks down both sides. */
.master-fader-track {
position: relative;
height: 100%;
width: 44px;
display: flex;
align-items: stretch;
justify-content: center;
}
.master-fader-track::before,
.master-fader-track::after {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
width: 5px;
background-image: repeating-linear-gradient(
to bottom,
var(--line-strong) 0,
var(--line-strong) 1px,
transparent 1px,
transparent 8px
);
pointer-events: none;
}
.master-fader-track::before { left: 4px; }
.master-fader-track::after { right: 4px; }
.master-fader {
/* No `appearance: slider-vertical` — that's a legacy webkit shortcut
that forces a blue-track default vertical slider AND ignores
::-webkit-slider-thumb overrides (which is why the thumb floats
left of center). `writing-mode: vertical-lr` rotates the
horizontal range input, `direction: rtl` puts max at the top. */
-webkit-appearance: none;
appearance: none;
-moz-appearance: none;
writing-mode: vertical-lr;
direction: rtl;
width: 100%;
height: 100%;
background: transparent;
cursor: pointer;
outline: none;
margin: 0;
padding: 0;
}
.master-fader::-webkit-slider-runnable-track {
width: 4px;
height: 100%;
background: #050505;
border: 1px solid var(--line);
border-radius: 2px;
}
.master-fader::-moz-range-track {
width: 4px;
height: 100%;
background: #050505;
border: 1px solid var(--line);
border-radius: 2px;
}
.master-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
/* In writing-mode: vertical-lr, `width` is the long (horizontal) axis
and `height` is the thin (vertical) axis. Pill-shaped horizontal
handle. margin-left: -12px centers it on the 4px track. */
width: 28px;
height: 14px;
margin-left: -12px;
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
border: 1px solid #050505;
border-radius: 3px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
0 1px 2px rgba(0, 0, 0, 0.7);
cursor: pointer;
}
.master-fader::-moz-range-thumb {
width: 28px;
height: 14px;
background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
border: 1px solid #050505;
border-radius: 3px;
cursor: pointer;
}
+418
View File
@@ -0,0 +1,418 @@
/* ───────────── Preview mixer ───────────── */
.mixer-column {
min-width: 0;
display: grid;
grid-template-columns: repeat(6, minmax(68px, 1fr));
gap: 10px;
}
.lane-header {
min-height: 190px;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
position: relative;
transition: opacity var(--t-base) var(--easing);
color: var(--stem-color, #d8dde4);
}
.lane-header[data-stem="vocals"] { --stem-color: #e54249; }
.lane-header[data-stem="drums"] { --stem-color: #f06a14; }
.lane-header[data-stem="bass"] { --stem-color: #eab414; }
.lane-header[data-stem="guitar"] { --stem-color: #5bbf50; }
.lane-header[data-stem="piano"] { --stem-color: #7f45d8; }
.lane-header[data-stem="other"] { --stem-color: #3e8dcf; }
.lane-header[data-stem="original"] { --stem-color: #a8b0bd; }
.lane-header + .lane-header {
border-left: 1px solid var(--glass-line);
}
.lane-header.muted {
opacity: 0.45;
}
.lane-stripe {
display: none;
}
.lane-content {
display: contents;
}
.lane-name-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 0;
}
.lane-icon {
width: 29px;
height: 29px;
flex: 0 0 auto;
color: var(--stem-color);
filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 34%, transparent));
}
.lane-icon-toggle {
width: 42px;
height: 42px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--stem-color);
cursor: pointer;
opacity: 0.38;
transition:
opacity var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.lane-icon-toggle.active {
opacity: 1;
border-color: color-mix(in srgb, currentColor 24%, transparent);
background: color-mix(in srgb, currentColor 8%, transparent);
box-shadow: inset 0 0 18px color-mix(in srgb, currentColor 8%, transparent);
}
.lane-icon-toggle:hover:not(:disabled) {
opacity: 1;
transform: translateY(-1px);
}
.lane-icon-toggle:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.lane-icon-toggle:disabled {
cursor: default;
opacity: 0.25;
}
.lane-name {
color: #d7dce3 !important;
font-size: 14px;
}
.lane-controls {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 106px;
align-items: end;
justify-items: center;
gap: 10px;
}
.lane-mini-wave {
display: none;
}
.lane-dl {
display: none;
color: #d8dde4;
text-decoration: none;
}
.lane-knob {
grid-column: 1 / -1;
grid-row: 1;
width: 48px;
height: 104px;
border-radius: 0;
border: 0;
background:
repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 0 1px, transparent 1px 12px),
linear-gradient(90deg, transparent 0 44%, rgba(148, 163, 184, 0.2) 44% 56%, transparent 56% 100%);
position: relative;
cursor: ns-resize;
/* --lane-pos: 0..1 = bottom..top. Default 0.5 = unity gain (vol=1
of LANE_VOLUME_MAX=2). JS rewrites this when volume changes. */
--lane-pos: 0.5;
}
/* Fader thumb. Travels vertically along the track based on --lane-pos.
The travel envelope is 100% - 18px (the thumb's own height) so the
thumb never overshoots the track edges. The transition is only for
programmatic moves (Reset, dblclick, wheel) -- during an active
drag we kill the transition (.lane-knob.dragging rule below) so the
thumb tracks the cursor 1:1 instead of chasing it through an
easing curve, which reads as a rigid/laggy fader. */
.lane-knob::before {
content: "";
position: absolute;
left: 50%;
top: calc((1 - var(--lane-pos)) * (100% - 14px));
width: 30px;
height: 14px;
transform: translateX(-50%);
border-radius: 3px;
border: 1px solid #050505;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 42%),
linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.7),
0 0 0 1px color-mix(in srgb, currentColor 34%, transparent),
0 1px 2px rgba(0, 0, 0, 0.72);
transition: top 120ms var(--easing);
will-change: top;
pointer-events: none;
}
.lane-knob.dragging::before {
transition: none;
}
/* Slight grip cue while actively dragging -- thumb gets a tighter
shadow ring so it feels "picked up". */
.lane-knob.dragging {
cursor: grabbing;
}
.lane-knob-indicator {
display: none;
}
.lane-knob.disabled {
opacity: 0.45;
pointer-events: none;
}
.ms-btn {
width: 30px;
height: 30px;
border: 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.055);
color: #d8dde4;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.ms-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.ms-btn:active {
transform: scale(0.92);
}
.ms-btn.mute.active {
background: var(--danger);
color: white;
}
.ms-btn.solo.active {
background: var(--gold);
color: #17130c;
}
.ms-btn:disabled {
opacity: 0.38;
pointer-events: none;
}
.lane-controls .ms-btn.mute {
grid-column: 1;
grid-row: 2;
}
.lane-controls .ms-btn.solo {
grid-column: 2;
grid-row: 2;
}
.lane-vu {
display: none;
}
.app:not(.is-import) .preview-mixer-body {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding-top: 0;
}
.app:not(.is-import) .mixer-column {
height: 100%;
grid-template-columns: repeat(var(--visible-track-count, 6), minmax(52px, 1fr));
gap: 0;
}
.app:not(.is-import) .lane-header {
min-height: 0;
height: 100%;
grid-template-rows: 64px 1fr;
padding: 7px 0 6px;
overflow: hidden;
}
.app:not(.is-import) .lane-name-row {
gap: 6px;
}
.app:not(.is-import) .lane-icon {
width: 26px;
height: 26px;
}
.app:not(.is-import) .lane-icon-toggle {
width: 38px;
height: 38px;
}
.app:not(.is-import) .lane-name {
font-size: 12px;
}
.app:not(.is-import) .lane-controls {
width: 100%;
height: auto;
min-height: 0;
align-self: stretch;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
gap: 0;
padding: 0 5px 38px;
}
.app:not(.is-import) .lane-knob {
width: 42px;
height: 100%;
min-height: 0;
justify-self: center;
}
.app:not(.is-import) .lane-vu {
display: block;
position: absolute;
left: 50%;
top: 72px;
bottom: 48px;
width: 12px;
height: auto;
transform: translateX(18px);
border-radius: 2px;
overflow: hidden;
background:
linear-gradient(to top,
rgba(48, 197, 87, 0.14) 0 55%,
rgba(225, 178, 46, 0.14) 55% 80%,
rgba(214, 64, 64, 0.14) 80% 100%),
rgba(4, 10, 15, 0.82);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
}
.app:not(.is-import) .lane-dl {
display: grid;
place-items: center;
position: absolute;
left: 8px;
right: 8px;
bottom: 6px;
height: 34px;
transform: none;
border: 1px solid color-mix(in srgb, var(--stem-color) 28%, rgba(148, 163, 184, 0.18));
border-radius: 8px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 13%, transparent), transparent),
rgba(255, 255, 255, 0.055);
color: var(--stem-color);
opacity: 0.9;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.035),
0 6px 14px rgba(0, 0, 0, 0.16);
transition:
opacity var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.app:not(.is-import) .lane-dl svg {
width: 19px;
height: 19px;
stroke-width: 2.15;
}
.app:not(.is-import) .lane-dl:hover:not(.disabled) {
opacity: 1;
border-color: color-mix(in srgb, var(--stem-color) 58%, transparent);
background:
linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 18%, transparent), transparent),
rgba(255, 255, 255, 0.075);
transform: translateY(-1px);
}
.app:not(.is-import) .lane-dl:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.app:not(.is-import) .lane-dl.disabled {
opacity: 0.28;
pointer-events: none;
}
.app:not(.is-import) .lane-vu::before {
content: "";
position: absolute;
left: 1px;
right: 1px;
top: 1px;
bottom: 1px;
background:
repeating-linear-gradient(to top, transparent 0 3px, rgba(0, 0, 0, 0.4) 3px 5px),
linear-gradient(to top, #24bf55 0 58%, #e1b22e 58% 80%, #d64040 80% 100%);
clip-path: inset(calc(100% - var(--vu-level, 0%)) 0 0 0);
transition: clip-path 30ms linear;
}
.app:not(.is-import) .lane-vu::after {
content: "";
position: absolute;
left: 1px;
right: 1px;
bottom: var(--vu-peak, 0%);
height: 2px;
background: rgba(255, 255, 255, 0.78);
transition: bottom 80ms linear;
}
.app:not(.is-import) .master-strip {
height: 100%;
min-height: 0;
grid-template-columns: 1fr;
grid-template-rows: 72px 1fr;
gap: 8px;
overflow: hidden;
}
.app:not(.is-import) .master-strip .master-fader-track {
height: calc(100% - 4px);
min-height: 0;
}
.app:not(.is-import) .master-strip .master-fader-track::before,
.app:not(.is-import) .master-strip .master-fader-track::after {
display: none;
}
+311
View File
@@ -0,0 +1,311 @@
/* ───────────── Responsive ─────────────
* Breakpoints:
* ≤1100px — laptop / portrait tablet: now-playing card reflows to 3-col
* with master spanning full width below transport.
* ≤900px — landscape phone / small tablet: appbar wraps, lanes scroll
* horizontally with a sticky mixer column, master compresses.
* ≤640px — phone: mixer column narrower, brand smaller, transport
* buttons full-width touch-friendly, master inline-compact.
* ≤480px — small phone: tightest layout, art shrinks, paddings minimal.
* pointer:coarse — touch input regardless of screen size: bump
* interactive targets to a comfortable hit area.
*/
@media (max-width: 1100px) {
.import-card {
width: min(100%, 900px);
}
.stem-choice-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stem-choice-row > span {
grid-column: 1 / -1;
}
.appbar {
grid-template-columns: 1fr;
}
.appbar-import-panel .import-input-row {
grid-template-columns: minmax(0, 1fr) minmax(144px, 180px);
}
.appbar-import-panel .stem-choice-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.appbar-import-panel .stem-choice-row > span {
grid-column: 1 / -1;
}
.feature-strip {
grid-template-columns: 1fr;
gap: 22px;
}
.feature-item + .feature-item {
border-left: 0;
border-top: 1px solid var(--gold-line);
padding-left: 0;
padding-top: 22px;
}
.now-playing {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"art info transport"
"art master master";
gap: 14px 16px;
}
.np-art { grid-area: art; }
.np-info { grid-area: info; }
.np-transport { grid-area: transport; }
.np-master { grid-area: master; }
}
@media (max-width: 900px) {
.app {
padding: 16px;
gap: 12px;
}
.appbar {
gap: 12px;
}
.import-page {
padding: 54px 18px 20px;
}
.import-copy h2 {
font-size: 31px;
}
.import-card {
margin-top: 36px;
padding: 18px;
}
.import-input-row {
grid-template-columns: 1fr;
}
.appbar-import-panel {
padding: 16px;
}
.appbar-import-panel .import-input-row {
grid-template-columns: 1fr;
gap: 12px;
}
.btn-primary {
width: 100%;
}
.brand-group {
flex: 1;
}
.appbar-form {
order: 3;
width: 100%;
flex: none;
}
/* Compress the master section to recover vertical space — the full
140px panel makes the now-playing card too tall when stacked. */
.np-master {
height: 90px;
padding: 6px 8px;
}
/* Lane scrolling: mixer column stays put, waves scroll under it. */
.lanes-body,
.lanes-ruler {
overflow-x: auto;
}
.mixer-column,
.lanes-ruler-gutter {
position: sticky;
left: 0;
z-index: 4;
background: var(--panel);
}
/* Keep the waves area readable when scrolling — without a min-width
the waveform canvases collapse below ~250px and become unusable. */
.waves-column,
.lanes-ruler-time {
min-width: 480px;
}
}
@media (max-width: 640px) {
:root {
--header-w: 200px;
}
.brand {
font-size: 22px;
}
.brand-logo {
width: 146px;
}
.brand-sub {
font-size: 12px;
}
.import-copy h2 {
font-size: 26px;
}
.import-copy p {
font-size: 15px;
}
.stem-choice-row {
grid-template-columns: 1fr 1fr;
}
.feature-strip {
padding: 22px;
}
.feature-item {
grid-template-columns: 52px 1fr;
}
.now-playing {
grid-template-columns: auto 1fr;
grid-template-areas:
"art info"
"transport master";
gap: 12px;
}
/* Inline transport + master side-by-side rather than stacked, so the
card stays compact. Master is already compressed by the 900px rule. */
.np-master {
height: 80px;
}
.np-transport {
align-self: center;
}
.lane-mini-wave {
display: none; /* save horizontal space in the mixer column */
}
.np-title {
font-size: 14px;
-webkit-line-clamp: 3;
}
}
@media (max-width: 480px) {
:root {
--header-w: 180px;
--lane-h: 84px;
}
.app {
padding: 12px;
gap: 10px;
}
/* Single-column stem buttons; the 2-col grid still squeezes labels at
~360px because each button needs the icon + text + 8px gap. */
.stem-choice-row {
grid-template-columns: 1fr;
}
.stem-choice {
min-height: 44px;
}
.feature-strip {
margin-top: 24px;
}
.privacy-note {
margin-top: 22px;
font-size: 13px;
text-align: center;
}
.now-playing {
padding: 10px 12px;
grid-template-columns: auto 1fr;
grid-template-areas:
"art info"
"transport transport"
"master master";
gap: 10px;
}
.np-art {
width: 56px;
height: 56px;
}
.np-art-play {
width: 28px;
height: 28px;
}
.np-master {
height: 72px;
}
.np-transport {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.chip {
padding: 3px 8px;
font-size: 12px;
}
/* Drop the second chip column to one row of just the essentials */
.np-chips {
gap: 6px;
}
/* Mixer rows: tighten internal padding so M/S/knob/cloud fit. */
.lane-content {
padding: 8px 36px 8px 8px;
}
.lane-name {
font-size: 13px;
}
/* Tighten lane VU on phone so the row stays readable. */
.lane-vu {
width: 18px;
right: 4px;
}
/* Keep waves usable — lower the min-width slightly on tiny screens. */
.waves-column,
.lanes-ruler-time {
min-width: 360px;
}
}
/* Touch-input devices: enlarge interactive targets to ≥40px hit area
regardless of screen size. Desktop stays compact. */
@media (pointer: coarse) {
.ms-btn {
width: 40px;
height: 40px;
font-size: 13px;
}
.lane-knob {
width: 40px;
height: 40px;
}
.lane-knob-indicator {
transform-origin: 50% 16px;
height: 11px;
top: 5px;
}
.lane-dl {
width: 36px;
height: 36px;
}
.btn-transport {
width: 44px;
height: 44px;
}
.avatar-btn {
width: 44px;
height: 44px;
}
/* Bigger master fader thumb so it's easier to grab. */
.master-fader::-webkit-slider-thumb {
width: 32px;
height: 18px;
margin-left: -14px;
}
.master-fader::-moz-range-thumb {
width: 32px;
height: 18px;
}
}
/* Landscape orientation on short screens (phones held sideways): the
now-playing card eats too much vertical space if it tries to stack.
Prefer a single horizontal row. */
@media (max-height: 500px) and (orientation: landscape) {
.now-playing {
grid-template-columns: auto 1fr auto auto;
grid-template-areas: "art info transport master";
gap: 10px;
padding: 8px 12px;
}
.np-master {
height: 70px;
}
.np-art {
width: 56px;
height: 56px;
}
}
+632
View File
@@ -0,0 +1,632 @@
/* ───────────── Import page ───────────── */
.import-page {
min-height: clamp(420px, 60vh, 704px);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
padding: clamp(28px, 6vw, 78px) clamp(14px, 3vw, 24px) clamp(18px, 3vw, 24px);
border: 1px solid var(--glass-line);
border-radius: 12px;
background:
radial-gradient(circle at 50% 22%, rgba(216, 168, 74, 0.1), transparent 23rem),
radial-gradient(circle at 8% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
radial-gradient(circle at 92% 45%, rgba(216, 168, 74, 0.06), transparent 22rem),
linear-gradient(180deg, rgba(16, 26, 34, 0.78), rgba(8, 15, 22, 0.88));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.import-copy {
text-align: center;
position: relative;
z-index: 2;
}
.import-copy h2 {
margin: 0;
color: #f1f3f5;
font-size: clamp(24px, 3.4vw + 8px, 39px);
line-height: 1.12;
font-weight: 700;
}
.import-copy h2 span {
color: var(--gold-bright);
white-space: nowrap;
}
.import-copy p {
margin: 16px 0 0;
color: #a9b0bb;
font-size: clamp(14px, 1vw + 8px, 18px);
}
.import-card {
width: min(100%, 1088px);
margin-top: clamp(20px, 4vw, 58px);
padding: clamp(14px, 2vw, 19px) clamp(16px, 3vw, 36px) clamp(18px, 2.5vw, 28px);
border-radius: 13px;
position: relative;
z-index: 2;
}
.import-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
padding-bottom: 18px;
border-bottom: 1px solid var(--glass-line);
}
.stem-choice-row {
display: grid;
grid-template-columns: 138px repeat(6, minmax(86px, 1fr));
gap: 14px;
align-items: center;
padding-top: 22px;
}
.stem-choice-row > span {
color: #d9dde4;
font-weight: 600;
white-space: nowrap;
}
.stem-choice {
min-height: 54px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
rgba(8, 15, 22, 0.34);
color: currentColor;
font: inherit;
font-weight: 700;
transition:
border-color var(--t-base) var(--easing),
background-color var(--t-base) var(--easing),
box-shadow var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.stem-choice:hover {
transform: translateY(-1px);
background-color: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 0 22px color-mix(in srgb, currentColor 10%, transparent);
}
/* Deselected state -- the user has unchecked this stem from the
"stems to extract" set, so it's greyed out and won't appear in the
studio dashboard after processing. Maintains layout (still occupies
its grid slot) so the row doesn't reflow on toggle. */
.stem-choice[aria-pressed="false"] {
opacity: 0.32;
border-color: rgba(148, 163, 184, 0.2) !important;
background: rgba(8, 15, 22, 0.2);
}
.stem-choice[aria-pressed="false"]:hover {
opacity: 0.55;
}
.stem-choice svg {
flex: 0 0 auto;
}
.stem-choice.vocals { color: #ff3f46; border-color: rgba(255, 63, 70, 0.28); }
.stem-choice.drums { color: #ff7a1a; border-color: rgba(255, 122, 26, 0.24); }
.stem-choice.bass { color: #ffd04d; border-color: rgba(255, 208, 77, 0.24); }
.stem-choice.guitar { color: #3fc952; border-color: rgba(63, 201, 82, 0.24); }
.stem-choice.piano { color: #974cff; border-color: rgba(151, 76, 255, 0.24); }
.stem-choice.other { color: #3695ff; border-color: rgba(54, 149, 255, 0.24); }
.privacy-note {
display: inline-flex;
align-items: center;
gap: 9px;
margin-top: 34px;
color: #a6adb7;
font-size: 15px;
position: relative;
z-index: 2;
}
.feature-strip {
width: min(100%, 1160px);
min-height: 136px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: clamp(18px, 2.4vw, 36px);
margin-top: clamp(22px, 4vw, 54px);
padding: clamp(16px, 2.4vw, 24px) clamp(18px, 3vw, 46px);
border-radius: 13px;
position: relative;
z-index: 2;
}
.feature-item {
display: grid;
grid-template-columns: clamp(40px, 4vw, 68px) 1fr;
align-items: center;
gap: clamp(12px, 1.5vw, 24px);
color: var(--gold);
}
.feature-item svg {
width: 100%;
height: auto;
max-width: 48px;
}
.feature-item + .feature-item {
border-left: 1px solid var(--gold-line);
padding-left: clamp(18px, 2.4vw, 42px);
}
.feature-item strong {
display: block;
color: var(--ink);
font-size: clamp(14px, 1vw + 6px, 16px);
margin-bottom: 6px;
}
.feature-item span {
color: #a6adb7;
font-size: clamp(13px, 0.8vw + 8px, 15px);
}
/* The HTML hard-codes <br> in feature descriptions for desktop layout;
on narrow viewports those forced breaks produce ragged 2-word lines.
Collapse them so the text wraps naturally. */
@media (max-width: 720px) {
.feature-item br { display: none; }
}
.wave-lines {
position: absolute;
top: 152px;
width: 32%;
height: 120px;
opacity: 0.46;
background:
repeating-linear-gradient(108deg, transparent 0 24px, rgba(216, 168, 74, 0.52) 25px 27px, transparent 28px 50px);
mask-image: radial-gradient(ellipse, black 0 48%, transparent 72%);
}
.wave-left {
left: 0;
transform: skewY(-7deg);
}
.wave-right {
right: 0;
transform: skewY(7deg);
}
/* Decorative diagonal lines crowd the headline on narrow viewports
(they're absolutely positioned at top: 152px; width: 32%) -- hide
them below the tablet breakpoint to keep the headline area clean. */
@media (max-width: 720px) {
.wave-lines { display: none; }
}
/* ───────────── Now-playing card ───────────── */
.now-playing {
display: grid;
grid-template-columns:
clamp(110px, 11vw, 160px)
minmax(0, 1.2fr)
minmax(0, 0.9fr)
minmax(0, 0.65fr)
minmax(0, 0.65fr)
minmax(0, 0.8fr);
align-items: center;
gap: 0;
min-height: 224px;
padding: 23px 24px;
border-radius: 12px;
}
.app:not(.is-import) .now-playing {
flex: 0 0 auto;
min-height: clamp(120px, 14vh, 160px);
padding-top: clamp(7px, 1vh, 12px);
padding-bottom: clamp(7px, 1vh, 12px);
}
.app:not(.is-import) .np-art {
width: clamp(92px, 13vh, 132px);
height: clamp(92px, 14vh, 144px);
}
.np-art {
position: relative;
width: 154px;
height: 168px;
border-radius: 9px;
overflow: hidden;
background:
linear-gradient(135deg, rgba(14, 22, 30, 0.95), rgba(5, 9, 13, 0.96)),
var(--panel-2);
flex-shrink: 0;
}
.np-art-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-faint);
background: linear-gradient(135deg, #1a1a1a, #0e0e0e);
}
.np-art-img {
position: absolute;
inset: 6px;
width: 100%;
height: 100%;
width: calc(100% - 12px);
height: calc(100% - 12px);
object-fit: contain;
display: none;
border-radius: 6px;
}
.np-art-img.loaded {
display: block;
}
.np-art-play {
width: 50px;
height: 50px;
border-radius: 50%;
border: 0;
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 12px 24px rgba(216, 168, 74, 0.2);
transition: transform var(--t-fast) var(--easing), background-color var(--t-base) var(--easing);
}
.np-art-play:hover {
background: linear-gradient(180deg, #ecc76e, #c78a30);
}
.np-art-play svg {
margin-left: 2px;
}
.np-info {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
padding: 0 clamp(14px, 2vw, 28px) 0 clamp(16px, 2.2vw, 30px);
border-right: 1px solid var(--glass-line);
}
.np-title {
font-size: clamp(19px, 1.45vw, 24px);
font-weight: 500;
color: var(--ink);
line-height: 1.2;
white-space: normal;
overflow: hidden;
text-overflow: clip;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
max-width: 100%;
}
.np-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 5px 12px;
background: rgba(255, 255, 255, 0.055);
color: var(--ink);
border: var(--border-w) solid var(--line);
border-radius: var(--radius-xs);
font-size: 14px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.np-play-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
margin-top: 6px;
color: #c6cbd2;
font-size: 14px;
}
.np-scrub {
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.16);
position: relative;
}
.np-scrub span {
position: absolute;
left: 0;
top: 50%;
width: 14px;
height: 14px;
transform: translateY(-50%);
border-radius: 50%;
background: var(--gold-bright);
box-shadow: 0 0 0 4px rgba(216, 168, 74, 0.18);
}
.stem-energy,
.analysis-card {
min-height: clamp(100px, 14vh, 156px);
padding: 0 clamp(10px, 1.4vw, 24px);
border-left: 1px solid var(--glass-line);
}
.stem-energy {
grid-column: 3;
}
.key-card {
grid-column: 4;
}
.loudness-card {
grid-column: 5;
}
.beat-card {
grid-column: 6;
}
.loudness-card.hidden + .beat-card {
grid-column: 5 / 7;
}
.stem-energy h3,
.analysis-card h3 {
margin: 0 0 13px;
color: #c9ced6;
font-size: 14px;
text-transform: uppercase;
}
.stem-energy h3 span,
.analysis-card h3 span,
.panel-title span {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
margin-left: 4px;
border: 1px solid currentColor;
border-radius: 50%;
font-size: 10px;
opacity: 0.72;
}
.energy-row {
display: grid;
grid-template-columns: 78px 1fr 40px;
align-items: center;
gap: 9px;
margin: 5px 0;
color: var(--stem-color);
font-size: 13px;
}
.energy-row span {
font-weight: 700;
}
.energy-row b {
height: 12px;
border-radius: 999px;
background:
linear-gradient(90deg, currentColor 0 var(--v, 0%), rgba(148, 163, 184, 0.13) var(--v, 0%) 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
transition: background 80ms linear;
}
.energy-row em {
color: #c8cdd4;
font-style: normal;
font-weight: 700;
}
.vocals { --stem-color: #e54249; color: var(--stem-color); }
.drums { --stem-color: #f06a14; color: var(--stem-color); }
.bass { --stem-color: #eab414; color: var(--stem-color); }
.guitar { --stem-color: #5bbf50; color: var(--stem-color); }
.piano { --stem-color: #7f45d8; color: var(--stem-color); }
.other { --stem-color: #3e8dcf; color: var(--stem-color); }
.original { --stem-color: #a8b0bd; color: var(--stem-color); }
.analysis-card {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.key-card {
align-items: center;
text-align: center;
}
.beat-card {
align-items: center;
text-align: center;
}
.analysis-card strong {
color: var(--gold-bright);
font-size: clamp(17px, 1.7vw, 26px);
line-height: 1.1;
font-weight: 500;
}
.analysis-card small {
color: #b9bfc7;
font-size: 12px;
}
.confidence-ring {
width: 66px;
height: 66px;
position: relative;
display: grid;
place-items: center;
border-radius: 50%;
background:
conic-gradient(
var(--gold) calc(var(--confidence-pct, 0) * 1%),
rgba(216, 168, 74, 0.16) 0
);
color: #e9edf2;
isolation: isolate;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.confidence-ring::before {
content: "";
position: absolute;
inset: 4px;
border-radius: 50%;
background: var(--panel);
}
.confidence-ring::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow:
inset 0 0 0 1px rgba(216, 168, 74, 0.12),
0 0 14px rgba(216, 168, 74, 0.1);
pointer-events: none;
}
.confidence-ring span {
position: relative;
z-index: 1;
}
.loudness-card strong {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: baseline;
color: #f0f2f5;
}
.beat-values {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.beat-values strong {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
align-items: baseline;
color: var(--gold-bright);
text-align: left;
}
.beat-values strong small {
display: block;
margin-top: 0;
color: #b9bfc7;
}
.np-transport {
display: flex;
align-items: center;
gap: 26px;
color: #cfd4db;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.np-transport-row {
display: flex;
align-items: center;
gap: 22px;
}
.btn-transport {
width: 58px;
height: 58px;
background: rgba(255, 255, 255, 0.055);
color: var(--ink);
border: 1px solid var(--glass-line);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color var(--t-base) var(--easing),
color var(--t-base) var(--easing),
border-color var(--t-base) var(--easing),
transform var(--t-fast) var(--easing);
}
.btn-transport:hover {
background: rgba(255, 255, 255, 0.09);
border-color: var(--line-strong);
}
.btn-transport:active {
transform: scale(0.94);
}
.btn-transport .pause-icon {
display: none;
}
.btn-transport.playing .pause-icon {
display: block;
}
.btn-transport.playing svg:not(.pause-icon) {
display: none;
}
.btn-transport.playing {
background: linear-gradient(180deg, #4ade80, #16a34a);
color: white;
border-color: #15803d;
box-shadow: 0 16px 34px rgba(34, 197, 94, 0.22);
}
.btn-transport.stopped {
background: linear-gradient(180deg, #f87171, #dc2626);
color: white;
border-color: #b91c1c;
box-shadow: 0 16px 34px rgba(239, 68, 68, 0.22);
}
.btn-transport.loop.active {
background: linear-gradient(180deg, var(--gold-bright), var(--gold));
color: white;
border-color: var(--gold-line);
box-shadow: 0 16px 34px rgba(216, 168, 74, 0.18);
}
.np-time {
font-size: 13px;
color: var(--ink-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+71
View File
@@ -0,0 +1,71 @@
/* ── Design tokens ── */
:root {
color-scheme: dark;
/* Surfaces */
--bg: #0b0f12;
--bg-2: #0f1418;
--panel: #131a1f;
--panel-2: #182026;
--panel-3: #1d262d;
--border: #232c34;
--border-strong: #2e3942;
/* Text */
--fg: #e8ecf0;
--fg-2: #c2c9d1;
--muted: #8a939c;
--muted-2: #5d666e;
/* Stem colours */
--vocals: #ef4444;
--drums: #f97316;
--bass: #eab308;
--guitar: #22c55e;
--piano: #a855f7;
--other: #9ca3af;
/* Accent */
--accent: #f4b740;
--accent-2: #d99a2b;
/* Legacy compat aliases used by waves.css / player.js */
--gold: var(--accent);
--gold-bright: #f8c054;
--gold-soft: rgba(244,183,64,0.16);
--gold-line: rgba(244,183,64,0.28);
--ink: var(--fg);
--ink-dim: var(--fg-2);
--ink-faint: var(--muted);
--line: var(--border);
--line-strong: var(--border-strong);
--glass-line: var(--border);
--danger: #d65a4a;
--focus-ring: rgba(236,236,236,0.55);
/* Typography */
--font-sans: 'Inter', -apple-system, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Layout */
--header-w: 300px;
--lane-h: 86px;
--radius: 10px;
--radius-sm: 8px;
--radius-xs: 6px;
/* Motion */
--t-fast: 80ms;
--t-base: 120ms;
--easing: cubic-bezier(0.2,0.8,0.2,1);
--shadow-panel: 0 4px 24px rgba(0,0,0,0.4);
}
@media (prefers-reduced-motion: reduce) {
:root { --t-fast: 0ms; --t-base: 0ms; }
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+1011
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="1200" height="320" viewBox="0 0 1200 320" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">StemDeck Horizontal Logo</title>
<desc id="desc">StemDeck logo with golden waveform symbol and wordmark.</desc>
<defs>
<linearGradient id="gold" x1="82" y1="48" x2="282" y2="264" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE38A"/>
<stop offset="0.48" stop-color="#F2B53D"/>
<stop offset="1" stop-color="#C98512"/>
</linearGradient>
</defs>
<g fill="url(#gold)">
<rect x="78" y="154" width="16" height="12" rx="6"/>
<rect x="110" y="118" width="20" height="84" rx="10"/>
<rect x="144" y="78" width="22" height="164" rx="11"/>
<rect x="178" y="126" width="18" height="68" rx="9"/>
<rect x="212" y="52" width="24" height="216" rx="12"/>
<rect x="250" y="106" width="20" height="108" rx="10"/>
<rect x="284" y="80" width="22" height="160" rx="11"/>
<rect x="318" y="128" width="20" height="64" rx="10"/>
<rect x="352" y="154" width="16" height="12" rx="6"/>
</g>
<text x="430" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F4F6F8">Stem</text>
<text x="678" y="178" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="104" font-weight="700" letter-spacing="-4" fill="#F2B53D">Deck</text>
<text x="435" y="232" font-family="Inter, Geist, Manrope, Arial, sans-serif" font-size="28" font-weight="500" letter-spacing="11" fill="#7F8A94">POWERED STEM SEPARATION</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+738
View File
@@ -0,0 +1,738 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>StemDeck — split any track into stems</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/variables.css" />
<link rel="stylesheet" href="/css/waves.css" />
<link rel="stylesheet" href="/css/daw.css" />
</head>
<body>
<div class="app daw" id="app">
<!-- ══ TOPBAR ══ -->
<div class="daw-topbar">
<!-- Brand -->
<div class="daw-brand">
<span class="daw-brand-name">
<span class="fg">Stem</span><span class="accent">Deck</span>
</span>
</div>
<span id="brandVersion" class="daw-version"></span>
<!-- Separator -->
<div class="daw-sep"></div>
<!-- Composer pill -->
<form id="job-form" class="daw-composer">
<!-- URL zone (JS uses .url-wrap for drag events) -->
<div class="daw-url-zone url-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="1.8" style="flex-shrink:0" aria-hidden="true">
<path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1 M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
</svg>
<input
id="url"
type="url"
placeholder="Paste a YouTube or SoundCloud link, or drop an audio file…"
spellcheck="false"
autocomplete="off"
/>
<!-- File pill (shown when file selected) -->
<div class="file-pill hidden" id="filePill">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
<button class="file-clear" id="fileClear" type="button" aria-label="Remove file">×</button>
</div>
<button class="daw-upload-btn" type="button" title="Upload audio file" id="uploadFileBtn" aria-label="Upload audio file">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12 M7 8l5-5 5 5 M5 21h14"/>
</svg>
</button>
<input id="fileInput" type="file" accept=".mp3,.wav,.flac,.mp4,.m4a,audio/mpeg,audio/wav,audio/flac,video/mp4,audio/mp4" style="display:none" aria-hidden="true" />
</div>
<!-- Divider -->
<div class="daw-composer-sep"></div>
<!-- Stem selection -->
<div class="daw-stem-section">
<span class="daw-extract-label">Extract</span>
<div class="daw-stem-chips">
<button class="stem-choice stem-choice-all" type="button" id="stemAllBtn" aria-pressed="true">All</button>
<button class="stem-choice" type="button" data-stem="vocals" aria-pressed="true" style="--color:var(--vocals)">
<span class="stem-dot"></span>Vocals
</button>
<button class="stem-choice" type="button" data-stem="drums" aria-pressed="true" style="--color:var(--drums)">
<span class="stem-dot"></span>Drums
</button>
<button class="stem-choice" type="button" data-stem="bass" aria-pressed="true" style="--color:var(--bass)">
<span class="stem-dot"></span>Bass
</button>
<button class="stem-choice" type="button" data-stem="guitar" aria-pressed="true" style="--color:var(--guitar)">
<span class="stem-dot"></span>Guitar
</button>
<button class="stem-choice" type="button" data-stem="piano" aria-pressed="true" style="--color:var(--piano)">
<span class="stem-dot"></span>Piano
</button>
<button class="stem-choice" type="button" data-stem="other" aria-pressed="false" style="--color:var(--other)">
<span class="stem-dot"></span>Other
</button>
</div>
</div>
<!-- Process button -->
<button id="submit" class="daw-process-btn" type="submit">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M13 2 3 14h7l-1 8 10-12h-7z"/>
</svg>
Split stems
</button>
</form>
<!-- Notification bell -->
<div class="daw-notif-wrap" style="position:relative;flex-shrink:0;">
<button class="daw-iconbtn daw-notif-btn" id="notifBtn" title="Notifications" type="button" aria-label="Notifications" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9 M13.7 21a2 2 0 0 1-3.4 0"/>
</svg>
<span class="daw-notif-badge hidden" id="notifBadge" aria-hidden="true"></span>
</button>
<div class="daw-notif-panel" role="menu" aria-label="Notifications">
<div class="daw-notif-header">
<span class="uplabel">Notifications</span>
<button class="daw-notif-close" type="button" aria-label="Close notifications">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="daw-notif-list" id="notifList">
<div class="daw-notif-card daw-notif-release hidden" id="notifReleaseCard">
<div class="daw-notif-card-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z"/></svg>
</div>
<div class="daw-notif-card-body">
<div class="daw-notif-card-title">New release available</div>
<div class="daw-notif-card-desc" id="notifReleaseDesc"></div>
</div>
<button class="daw-notif-dismiss" id="notifReleaseDismiss" type="button" aria-label="Dismiss notification">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<p class="daw-notif-empty" id="notifEmpty">No new notifications</p>
</div>
</div>
</div>
<!-- Hidden collapsed strip (JS compatibility) -->
<div class="hidden" id="appbarStrip" aria-hidden="true" style="display:none!important">
<div><div class="strip-sq-stems" id="appbarStripStems"></div></div>
</div>
</div>
<!-- ══ BODY ══ -->
<div class="daw-body">
<!-- ── Sidebar / Catalog ── -->
<aside class="sidebar" id="catalogPanel">
<!-- Rail -->
<nav class="sidebar-rail" aria-label="Library navigation">
<button class="rail-btn rail-collapse" id="sidebarCollapseBtn" type="button" title="Toggle library" aria-label="Toggle library" aria-expanded="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<button class="rail-btn rail-library active" id="appMenuBtn" type="button" title="Library" aria-label="Library" aria-pressed="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M4 4h16v16H4z M4 9h16"/>
</svg>
<span>Library</span>
</button>
<button class="rail-btn rail-favorites" type="button" title="Favorites" aria-label="Favorites" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
<span>Favorites</span>
</button>
<button class="rail-btn rail-trash" type="button" title="Trash" aria-label="Trash" aria-pressed="false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
<span>Trash</span>
</button>
<div style="flex:1"></div>
<button class="rail-btn" id="settingsBtn" type="button" aria-label="Settings" aria-haspopup="dialog">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2 M12 20v2 M4 12H2 M22 12h-2 M5 5l1.5 1.5 M17.5 17.5 19 19 M5 19l1.5-1.5 M17.5 6.5 19 5"/>
</svg>
<span>Settings</span>
</button>
<button class="rail-btn rail-recommend" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
<span>We<br>Recommend</span>
</button>
<button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .8-1 1.7 M12 17h.01"/>
</svg>
<span>Help</span>
</button>
</nav>
<!-- Sidebar body -->
<div class="sidebar-body">
<!-- Search -->
<div class="daw-search" id="catalogToggle" role="button" tabindex="0" aria-label="Search library">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
</svg>
<input id="catalogSearch" type="search" placeholder="Search or #tag…" autocomplete="off" spellcheck="false" />
<ul class="tag-suggest" id="tagSuggest" role="listbox" aria-label="Tag suggestions"></ul>
<span class="search-kbd" aria-hidden="true">⌘K</span>
</div>
<!-- Clear bin (visible only in trash-view) -->
<div id="clearBinBar" class="clear-bin-bar">
<button id="clearBinBtn" class="clear-bin-btn" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18 M8 6V4h8v2 M19 6l-1 14H6L5 6"/>
</svg>
Empty trash
</button>
</div>
<!-- Catalog list (JS populates) -->
<div id="catalogList" class="daw-lib-list" role="list"></div>
<!-- Collapsed strip (JS compat) -->
<div id="catalogStrip" class="cat-collapsed-strip"></div>
</div>
</aside>
<!-- ── Main area ── -->
<div class="daw-main-col">
<div id="lanes" class="daw-main">
<!-- Job progress -->
<div id="error" class="error hidden" role="alert"></div>
<!-- Track header / info panel -->
<div id="transport" class="daw-track-header">
<!-- Row 1: track card + metadata cards -->
<div class="daw-info-row">
<div class="daw-meta-card" data-meta="key">
<span class="meta-card-label">KEY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-key"></span>
<div class="daw-camelot-ring hidden" id="summary-confidence"></div>
</div>
<span class="meta-card-sub" id="summary-scale"></span>
<span class="meta-card-sub hidden" id="summary-confidence-label"></span>
</div>
<div class="daw-meta-card" data-meta="bpm">
<span class="meta-card-label">BPM</span>
<div class="meta-card-main">
<span class="meta-card-value accent" id="summary-bpm"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="lufs">
<span class="meta-card-label">LUFS</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-lufs"></span>
</div>
<span class="meta-card-sub" id="summary-peak"></span>
</div>
<div class="daw-meta-card" data-meta="duration">
<span class="meta-card-label">DURATION</span>
<div class="meta-card-main">
<span class="meta-card-value num" id="summary-duration"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="scale">
<span class="meta-card-label">SCALE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-scale-name"></span>
</div>
</div>
<div class="daw-meta-card" data-meta="dr">
<span class="meta-card-label">DYNAMIC RANGE</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-dr"></span>
</div>
<span class="meta-card-sub" id="summary-dr-label"></span>
</div>
<div class="daw-meta-card" data-meta="stability">
<span class="meta-card-label">TEMPO STABILITY</span>
<div class="meta-card-main">
<span class="meta-card-value" id="summary-stability"></span>
</div>
<span class="meta-card-sub" id="summary-stability-label"></span>
</div>
</div>
<!-- Row 2: stem presence cards -->
<div class="stem-presence-panel">
<div class="stem-card inactive" data-stem="vocals">
<span class="stem-card-label">VOCAL PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="drums">
<span class="stem-card-label">DRUM INTENSITY</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="bass">
<span class="stem-card-label">BASS DEPTH</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="guitar">
<span class="stem-card-label">GUITAR PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="piano">
<span class="stem-card-label">PIANO PRESENCE</span>
<span class="stem-card-pct"></span>
</div>
<div class="stem-card inactive" data-stem="other">
<span class="stem-card-label">OTHER</span>
<span class="stem-card-pct"></span>
</div>
</div>
<!-- Hidden compat -->
<div style="display:none" aria-hidden="true"><div id="loudness-card"></div></div>
</div>
<!-- Sections bar -->
<div class="daw-section-ribbon">
<div class="daw-mixer-label daw-sections-header">
<span class="daw-label-title">Sections</span>
<span class="sections-save-indicator hidden" id="sectionsSaveIndicator" aria-live="polite" aria-label="Saving sections"></span>
<button class="sections-add-btn-label" id="sectionsAddBtn" type="button" title="Add section" aria-label="Add section">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
Add
</button>
</div>
<div class="daw-sections-area" id="daw-sections"></div>
</div>
<!-- Waveform header / ruler -->
<div class="daw-wave-header">
<div class="daw-wave-label">
<span class="daw-label-title">Mixer</span>
<span class="uplabel daw-label-sub">Drag fader · M/S</span>
</div>
<div class="daw-ruler-area">
<div class="lanes-ruler" data-position="top">
<div class="lanes-ruler-time" id="ruler-time">
<div class="playhead-marker" aria-hidden="true">
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<polygon points="0,0 10,0 5,8" fill="var(--accent)"/>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Content area: stems + waveform -->
<div class="daw-content">
<!-- Stems panel (left — M/S controls per stem) -->
<div class="daw-stems-panel" aria-label="Stems">
<!-- Presence panel hidden but in DOM for JS -->
<div style="display:none" aria-hidden="true">
<section class="presence-panel surface-panel">
<div class="presence-ruler" id="presence-ruler">
<span></span><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b><b>0:00</b>
</div>
<div class="presence-grid">
<div class="presence-labels">
<span class="vocals">Vocals</span><span class="drums">Drums</span>
<span class="bass">Bass</span><span class="guitar">Guitar</span>
<span class="piano">Piano</span><span class="other">Others</span>
</div>
<div class="presence-bars">
<i class="vocals" data-stem="vocals"></i>
<i class="drums" data-stem="drums"></i>
<i class="bass" data-stem="bass"></i>
<i class="guitar" data-stem="guitar"></i>
<i class="piano" data-stem="piano"></i>
<i class="other" data-stem="other"></i>
<div class="presence-playhead hidden" id="presence-playhead" aria-hidden="true"></div>
</div>
</div>
</section>
</div>
<!-- Stem rows with M/S -->
<div class="stem-list" aria-label="Waveform lane icons">
<span class="original" data-stem="original">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Original
</em>
<b class="stem-mute" data-stem="original" role="button" tabindex="0" aria-label="Mute Original" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="original" role="button" tabindex="0" aria-label="Solo Original" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="original" aria-label="Solo only Original"></button>
<i class="mini-meter"></i>
</span>
<span class="vocals" data-stem="vocals">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
Vocals
</em>
<b class="stem-mute" data-stem="vocals" role="button" tabindex="0" aria-label="Mute Vocals" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="vocals" role="button" tabindex="0" aria-label="Solo Vocals" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="vocals" aria-label="Solo only Vocals"></button>
<i class="mini-meter"></i>
</span>
<span class="drums" data-stem="drums">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M7 13.5a5 5 0 0 0 10 0"/><path d="M7 13.5h10"/><circle cx="9" cy="10" r="2.5"/><circle cx="15" cy="10" r="2.5"/></svg>
Drums
</em>
<b class="stem-mute" data-stem="drums" role="button" tabindex="0" aria-label="Mute Drums" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="drums" role="button" tabindex="0" aria-label="Solo Drums" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="drums" aria-label="Solo only Drums"></button>
<i class="mini-meter"></i>
</span>
<span class="bass" data-stem="bass">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16.5 3h4v5h-3"/><path d="M17.5 5.5 9.8 13.2"/><path d="M10 13c1.6 2.2 1.1 5.1-1.2 6.5-2.1 1.3-5 .5-6-1.6-.9-1.9-.1-4.1 1.8-5 .9-.4 1.8-.4 2.8-.1.1-1.1.6-2.1 1.6-2.6 1.2-.6 2.6-.1 3.2 1.1"/></svg>
Bass
</em>
<b class="stem-mute" data-stem="bass" role="button" tabindex="0" aria-label="Mute Bass" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="bass" role="button" tabindex="0" aria-label="Solo Bass" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="bass" aria-label="Solo only Bass"></button>
<i class="mini-meter"></i>
</span>
<span class="guitar" data-stem="guitar">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M16 4.5 20 2l2 2-2.5 4"/><path d="M18.2 5.8 10.2 13.8"/><circle cx="7" cy="16.4" r="1.4"/></svg>
Guitar
</em>
<b class="stem-mute" data-stem="guitar" role="button" tabindex="0" aria-label="Mute Guitar" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="guitar" role="button" tabindex="0" aria-label="Solo Guitar" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="guitar" aria-label="Solo only Guitar"></button>
<i class="mini-meter"></i>
</span>
<span class="piano" data-stem="piano">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 5v14"/><path d="M12 5v14"/><path d="M17 5v14"/></svg>
Piano
</em>
<b class="stem-mute" data-stem="piano" role="button" tabindex="0" aria-label="Mute Piano" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="piano" role="button" tabindex="0" aria-label="Solo Piano" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="piano" aria-label="Solo only Piano"></button>
<i class="mini-meter"></i>
</span>
<span class="other" data-stem="other">
<i class="drag-handle"></i>
<em>
<svg class="stem-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 13v-2"/><path d="M8 17V7"/><path d="M12 21V3"/><path d="M16 17V7"/><path d="M20 13v-2"/></svg>
Others
</em>
<b class="stem-mute" data-stem="other" role="button" tabindex="0" aria-label="Mute Other" aria-pressed="false">M</b>
<b class="stem-solo" data-stem="other" role="button" tabindex="0" aria-label="Solo Other" aria-pressed="false">S</b>
<button class="stem-monitor" type="button" data-stem="other" aria-label="Solo only Other"></button>
<i class="mini-meter"></i>
</span>
</div>
<!-- Mixer faders (JS populates) -->
<div id="mixer" class="mixer-column"></div>
</div>
<!-- Waveform panel -->
<div class="daw-wave-panel wave-editor surface-panel">
<div id="job" class="job hidden" role="status" aria-live="polite">
<div class="job-header">
<div id="job-title" class="title"></div>
<div id="job-stage" class="stage"></div>
<button id="job-cancel" class="cancel-btn hidden" type="button" aria-label="Cancel job">Cancel</button>
</div>
<progress id="progress" max="100" value="0"></progress>
<div id="job-detail" class="job-detail" aria-live="polite"></div>
</div>
<div class="wave-scroll" id="wave-scroll">
<div class="wave-canvas" id="wave-canvas">
<div class="waves-column">
<div id="waves-grid" class="waves-grid" aria-hidden="true"></div>
<div id="multitrack-container"></div>
<div id="loop-region" class="loop-region hidden" aria-hidden="true"></div>
</div>
<div class="wave-loading-overlay hidden" id="waveLoadingOverlay" aria-label="Loading waveform" aria-live="polite">
<div class="wave-loading-lines" aria-hidden="true">
<i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<p id="waveLoadingPhrase" class="wave-loading-msg"></p>
</div>
</div>
</div>
</div>
</div><!-- /.daw-content -->
<!-- Transport footer -->
</div><!-- /#lanes -->
<!-- ══ FOOTER — sibling of #lanes inside .daw-main-col, not clipped ══ -->
<footer class="daw-footer">
<!-- Top row: track info | transport + time | chips -->
<div class="footer-content">
<div class="footer-track">
<div class="footer-track-title-row">
<div class="daw-track-title footer-track-title" id="title"></div>
<button class="daw-fav-btn" id="fav-btn" aria-label="Toggle favorite" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
</div>
<div class="footer-track-body">
<div class="daw-cover footer-cover" id="np-art">
<div class="np-art-placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
</div>
<img id="np-thumb" class="np-art-img" alt="" referrerpolicy="no-referrer" />
</div>
<div class="daw-track-meta">
<div class="daw-track-time-row">
<span class="num daw-track-sub" id="t-time"></span>
<span class="daw-track-sep" aria-hidden="true">|</span>
<span class="daw-track-stems-label" id="t-stems-chip">— Stems</span>
</div>
<div class="daw-track-details">
<div class="daw-detail-row">
<span class="daw-detail-label">Extracted</span>
<span class="daw-detail-val" id="track-extracted"></span>
</div>
<div class="daw-detail-row">
<span class="daw-detail-label">Source</span>
<span class="daw-detail-val" id="track-source"></span>
</div>
<div class="daw-detail-row">
<span class="daw-detail-label">Quality</span>
<span class="daw-detail-val" id="track-quality"></span>
</div>
</div>
</div>
</div><!-- /.footer-track-body -->
<!-- Hidden spans for JS compat -->
<div style="display:none" aria-hidden="true">
<span id="t-bpm"></span><span id="t-key"></span><span id="track-artist"></span>
</div>
</div>
<div class="footer-center">
<div class="footer-transport">
<button class="daw-iconbtn btn-transport" id="t-stop" type="button" title="Stop" aria-label="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12"/>
</svg>
</button>
<button class="daw-play-btn btn-transport" id="t-play" type="button" aria-label="Play / Pause">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="margin-left:2px">
<path d="M6 4l14 8L6 20z"/>
</svg>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" class="pause-icon" aria-hidden="true" style="display:none">
<rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/>
</svg>
</button>
<button class="daw-iconbtn btn-transport loop" id="t-loop" type="button" title="Loop (L)" aria-label="Loop">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M17 2l4 4-4 4 M3 12V8a2 2 0 0 1 2-2h16 M7 22l-4-4 4-4 M21 12v4a2 2 0 0 1-2 2H3"/>
</svg>
</button>
<div class="footer-loop-times" title="Exact loop start / end (mm:ss.mmm or seconds)">
<input type="text" id="t-loop-start" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop start" value="00:00.000">
<span class="loop-times-sep" aria-hidden="true">-</span>
<input type="text" id="t-loop-end" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop end" value="00:00.000">
</div>
</div>
<div class="footer-time-row">
<span class="num footer-elapsed" id="footer-time-elapsed">0:00</span>
<span class="footer-time-sep">/</span>
<span class="num footer-total" id="footer-time-total">0:00</span>
</div>
<div class="tempo-bar" id="t-speed-wrap" title="Playback speed (double-click to reset)">
<span class="tempo-bar-label">TEMPO</span>
<input type="range" id="t-speed" min="0" max="2" step="0.25" value="1" aria-label="Playback speed">
<span class="tempo-bar-divider" aria-hidden="true"></span>
<span class="tempo-bar-val" id="t-speed-label">1.0x</span>
</div>
</div>
<div class="footer-chips">
<div class="footer-chip-wrap" id="footer-export-wrap">
<button class="footer-chip footer-chip-primary" id="t-export-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="t-export-panel">
<svg class="export-dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg class="export-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke-opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9"/>
</svg>
<span class="export-btn-label" id="t-export-label">Export Mix</span>
<svg class="export-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="footer-chip-panel chip-panel-export hidden" id="t-export-panel" role="menu" aria-label="Export options">
<div class="export-format-toggle" role="radiogroup" aria-label="Export format">
<button class="export-fmt active" id="t-fmt-wav" type="button" role="radio" aria-checked="true">WAV</button>
<button class="export-fmt" id="t-fmt-mp3" type="button" role="radio" aria-checked="false">MP3</button>
<button class="export-fmt" id="t-fmt-flac" type="button" role="radio" aria-checked="false">FLAC</button>
<button class="export-fmt export-fmt-video" id="t-fmt-mp4" type="button" role="radio" aria-checked="false">MP4</button>
</div>
<button class="chip-panel-item export-item" id="t-export-mix" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><line x1="4" y1="9" x2="4" y2="15"/><line x1="9" y1="5" x2="9" y2="19"/><line x1="14" y1="8" x2="14" y2="16"/><line x1="19" y1="4" x2="19" y2="20"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export Mix</span>
<span class="chip-item-desc">Export the mixed audio</span>
</span>
<svg class="chip-item-check" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button class="chip-panel-item export-item" id="t-export-stems" type="button" role="menuitem">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><polygon points="12 2 22 8.5 12 15 2 8.5 12 2"/><polyline points="2 15.5 12 22 22 15.5"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export All Stems</span>
<span class="chip-item-desc">All stems as a .zip</span>
</span>
</button>
<button class="chip-panel-item export-item" id="t-export-region" type="button" role="menuitem" aria-disabled="true">
<span class="chip-item-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>
</span>
<span class="chip-item-text">
<span class="chip-item-title">Export Current Region</span>
<span class="chip-item-desc">Export selected region</span>
</span>
</button>
</div>
</div>
</div>
</div><!-- /.footer-content -->
<!-- Full-width waveform bar -->
<div class="footer-wave-bar">
<canvas id="footer-waveform" aria-hidden="true"></canvas>
<div class="footer-scrub" id="footer-scrub" role="slider" aria-label="Seek" tabindex="0">
<div class="footer-scrub-fill" id="footer-scrub-fill"></div>
</div>
</div>
</footer>
</div><!-- /.daw-main-col -->
</div><!-- /.daw-body -->
</div><!-- /.daw -->
<!-- About dialog -->
<div class="about-backdrop hidden" id="aboutDialog" role="dialog" aria-modal="true" aria-labelledby="aboutTitle">
<div class="about-card">
<button class="about-close" id="aboutClose" type="button" aria-label="Close about dialog">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="2" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
<rect x="7" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="12" y="2" width="4" height="24" rx="2" fill="currentColor"/>
<rect x="18" y="6" width="3" height="16" rx="1.5" fill="currentColor" opacity=".7"/>
<rect x="23" y="10" width="3" height="8" rx="1.5" fill="currentColor" opacity=".5"/>
</svg>
</div>
<h2 id="aboutTitle">StemDeck</h2>
<p class="about-tagline">Open source. No subscriptions. Built by musicians, for musicians.</p>
<span class="about-version-badge" id="aboutVersion"></span>
<div class="about-primary-links">
<a class="about-link about-link-primary" href="https://stemdeck.app" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Website
</a>
<a class="about-link about-link-secondary" href="https://github.com/stemdeckapp/stemdeck" target="_blank" rel="noopener noreferrer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
GitHub
</a>
</div>
<div class="about-divider"></div>
<div class="about-socials">
<a class="about-social-btn" href="https://discord.gg/JGk7FdZb9N" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
</a>
<a class="about-social-btn" href="https://www.reddit.com/r/StemDeckApp/" target="_blank" rel="noopener noreferrer" title="Reddit" aria-label="Reddit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</a>
<a class="about-social-btn" href="https://www.instagram.com/stemdeck" target="_blank" rel="noopener noreferrer" title="Instagram" aria-label="Instagram">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
</a>
<a class="about-social-btn" href="https://x.com/StemDeckApp" target="_blank" rel="noopener noreferrer" title="X" aria-label="X">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
</div>
</div>
</div>
<!-- Our Friends dialog -->
<div class="about-backdrop hidden" id="friendsDialog" role="dialog" aria-modal="true" aria-labelledby="friendsTitle">
<div class="about-card friends-card">
<button class="about-close" id="friendsClose" type="button" aria-label="Close friends dialog">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>
</svg>
</div>
<h2 id="friendsTitle">We Recommend</h2>
<p class="about-tagline">Wonderful people doing beautiful work. Go meet them ❤️</p>
<div class="lib-friends-grid" id="friendsDialogGrid"></div>
</div>
</div>
<script type="module" src="/js/main.js"></script>
<script type="module" src="/js/ui-chrome.js"></script>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
import { TRACK_NAMES } from "./constants.js";
import {
multitrack, mixerEl, audioContext, trackAnalysers,
vuRafId, trackIndex,
setAudioContext, setVuRafId,
} from "./state.js";
export function attachAnalysers() {
if (!multitrack) return;
if (trackAnalysers.length) return;
const wsArr = multitrack.wavesurfers || multitrack._wavesurfers;
const audios = multitrack.audios;
if (!wsArr?.length || !audios?.length) return;
const ctx = multitrack.audioContext;
if (!ctx) return;
if (ctx.state === "suspended") ctx.resume().catch(() => {});
setAudioContext(ctx);
for (const stemName of TRACK_NAMES) {
const idx = trackIndex[stemName];
if (idx === undefined) continue;
const audioEl = audios[idx];
if (!audioEl) continue;
const isHtmlMedia = audioEl instanceof HTMLMediaElement;
const isWebAudio = typeof audioEl.getGainNode === "function";
if (!isHtmlMedia && !isWebAudio) continue;
if (isHtmlMedia && !audioEl.src) continue;
let analyser;
try {
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
if (isWebAudio) {
// wavesurfer's audio wrapper exposes its internal gain node;
// analyser taps the post-gain signal.
audioEl.getGainNode().connect(analyser);
} else {
// Bare HTMLAudioElement: route through Web Audio so we can tap
// the post-gain signal. applyMix() may have already created the
// MediaElementSource + GainNode chain; re-use it if so (the spec
// only allows createMediaElementSource once per element).
if (!audioEl._stMediaSource) {
audioEl._stMediaSource = ctx.createMediaElementSource(audioEl);
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
} else if (!audioEl._stGainNode) {
audioEl._stGainNode = ctx.createGain();
audioEl._stMediaSource.connect(audioEl._stGainNode);
audioEl._stGainNode.connect(ctx.destination);
audioEl.volume = 1;
}
audioEl._stGainNode.connect(analyser);
}
} catch (err) {
console.warn("VU analyser hookup failed for stem", stemName, err);
continue;
}
// Time-domain buffer must be sized to fftSize (not frequencyBinCount,
// which is fftSize/2). With a too-small buffer, getByteTimeDomainData
// only writes the first N samples and trailing entries keep stale
// values, biasing RMS toward whatever was last left there.
const data = new Uint8Array(analyser.fftSize);
const vuEl = mixerEl.querySelector(`.lane-vu[data-stem="${stemName}"]`);
const miniMeterEl = document.querySelector(`.stem-list .${stemName} .mini-meter`);
trackAnalysers.push({ analyser, data, vuEl, miniMeterEl, peak: 0 });
}
const tick = () => {
for (const t of trackAnalysers) {
t.analyser.getByteTimeDomainData(t.data);
let sum = 0;
for (let i = 0; i < t.data.length; i++) {
const v = (t.data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / t.data.length);
// Linear RMS with 2.5x gain. Compensates for the 0.5 master
// attenuation the analyser sees post-gain via createMediaElementSource,
// so typical -20 dBFS music lands in the 25-50% bar range, drum
// hits push toward 80-100%, silence falls to 0. Linear (not dB)
// gives the meter punch on transients that dB scaling smooths out.
const level = Math.min(1, rms * 2.5);
// Peak hold: bar follows level on the way up, falls slowly on
// the way down. That's what makes a VU look alive -- it lifts
// sharply on each drum hit / vocal phrase and drifts down in
// between rather than tracking instantaneous RMS jitter.
const prevPeak = t.peak || 0;
const nextPeak = level > prevPeak ? level : Math.max(0, prevPeak - 0.012);
t.peak = nextPeak;
// Separate peak-hold marker that holds the recent max for
// ~600 ms before falling, so a thin tick visually marks
// "loudest moment in the last second" above the bar.
const prevHold = t.peakHold || 0;
let nextHold = prevHold;
let holdFrames = t.holdFrames || 0;
if (level > prevHold) {
nextHold = level;
holdFrames = 36;
} else if (holdFrames > 0) {
holdFrames -= 1;
} else {
nextHold = Math.max(0, prevHold - 0.02);
}
t.peakHold = nextHold;
t.holdFrames = holdFrames;
const lvlPct = Math.round(level * 100);
const peakPct = Math.round(nextPeak * 100);
const holdPct = Math.round(nextHold * 100);
if (t.vuEl) {
if (lvlPct !== t.lastLevelPct) {
t.vuEl.style.setProperty("--vu-level", `${lvlPct}%`);
}
if (holdPct !== t.lastHoldPct) {
t.vuEl.style.setProperty("--vu-peak", `${holdPct}%`);
}
}
if (t.miniMeterEl) {
if (peakPct !== t.lastPeakPct) {
t.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3));
}
if (holdPct !== t.lastHoldPct) {
t.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct));
t.miniMeterEl.style.setProperty(
"--vu-peak-opacity",
nextHold > 0.04 ? "1" : "0",
);
}
}
t.lastLevelPct = lvlPct;
t.lastPeakPct = peakPct;
t.lastHoldPct = holdPct;
}
setVuRafId(requestAnimationFrame(tick));
};
setVuRafId(requestAnimationFrame(tick));
}
export function stopVuLoop() {
if (vuRafId) {
cancelAnimationFrame(vuRafId);
setVuRafId(null);
}
}
+218
View File
@@ -0,0 +1,218 @@
// Web Audio decode-and-mix playback engine.
//
// Safari/WKWebView goes choppy when playing N streaming <audio> elements (one per
// stem) over HTTP/1.1: the 6-connection-per-origin cap + small media buffers + the
// multitrack's per-element currentTime nudging cause underruns. This engine instead
// decodes each active stem once into an AudioBuffer and plays them all from a single
// AudioContext clock — sample-accurate, zero streaming connections during playback,
// no drift. Works identically on WKWebView, Safari, and Chrome.
//
// Graph: per-stem AudioBufferSourceNode -> GainNode (vol/mute/solo) -> AnalyserNode (VU)
// -> masterGain -> SoundTouchNode -> destination
//
// Used behind a feature flag (see player.js) so it can be A/B'd against the legacy
// streaming path before cutover.
const AudioCtx = window.AudioContext || window.webkitAudioContext;
/**
* @param {{name:string,url:string}[]} stems Active stems only (caller filters).
* @param {{onTime?:(t:number)=>void, onEnded?:()=>void}} cbs
*/
export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// Mobile/iOS only starts audio from a context resumed inside a user gesture.
// Callers can pass a shared, gesture-unlocked `context` (the mobile UI does);
// desktop passes none and we own a fresh one. We only close contexts we own.
const ctx = context || new AudioCtx();
const ownsCtx = !context;
const master = ctx.createGain();
// SoundTouch pitch-preserving time-stretch on the master bus.
// Falls back to tape-effect (playbackRate) if AudioWorklet is unavailable.
let stNode = null;
const _workletReady = (ctx.audioWorklet
? ctx.audioWorklet.addModule('/vendor/soundtouch-processor.js').then(() => {
stNode = new AudioWorkletNode(ctx, 'soundtouch-processor');
master.connect(stNode);
stNode.connect(ctx.destination);
}).catch((err) => {
console.warn('[audioEngine] SoundTouch worklet load failed, using tape-effect fallback:', err);
master.connect(ctx.destination);
})
: Promise.resolve().then(() => { master.connect(ctx.destination); }));
/** @type {Map<string,{buffer:AudioBuffer,gain:GainNode,analyser:AnalyserNode,source:AudioBufferSourceNode|null}>} */
const tracks = new Map();
let duration = 0;
let playing = false;
let startCtxTime = 0; // ctx.currentTime at playback start
let startOffset = 0; // media offset at that moment
let rafId = null;
let destroyed = false;
let loop = { enabled: false, start: 0, end: 0 };
let _playbackRate = 1.0;
// Decode all stems up front AND load the SoundTouch worklet in parallel.
// Resolves true once at least one stem is ready (worklet load is best-effort).
const ready = (async () => {
await Promise.all([
_workletReady,
...stems.map(async (s) => {
if (!s?.url) return;
try {
const res = await fetch(s.url);
if (!res.ok) throw new Error(`fetch ${res.status}`);
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
if (destroyed) return;
const gain = ctx.createGain();
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
gain.connect(analyser);
analyser.connect(master);
tracks.set(s.name, { buffer, gain, analyser, source: null });
duration = Math.max(duration, buffer.duration);
} catch (e) {
console.warn(`[audioEngine] decode failed for ${s.name}:`, e);
}
}),
]);
return tracks.size > 0;
})();
const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset);
function stopSources() {
for (const t of tracks.values()) {
if (t.source) {
try { t.source.stop(); } catch { /* already stopped */ }
try { t.source.disconnect(); } catch { /* noop */ }
t.source = null;
}
}
}
function startSources(offset) {
const when = ctx.currentTime;
for (const t of tracks.values()) {
const src = ctx.createBufferSource();
src.buffer = t.buffer;
// SoundTouch handles time-stretch; playbackRate stays 1.0.
// Falls back to tape-effect only when the worklet is unavailable.
if (!stNode) src.playbackRate.value = _playbackRate;
src.connect(t.gain);
src.start(when, Math.max(0, Math.min(offset, t.buffer.duration)));
t.source = src;
}
startCtxTime = when;
startOffset = offset;
}
function tick() {
if (!playing) return;
let t = now();
if (loop.enabled && loop.end > loop.start && t >= loop.end) {
seek(loop.start);
t = loop.start;
} else if (t >= duration) {
pause();
startOffset = duration;
onTime?.(duration);
onEnded?.();
return;
}
onTime?.(t);
rafId = requestAnimationFrame(tick);
}
function play() {
if (playing || destroyed || !tracks.size) return;
// Safari: resume the context fire-and-forget within the user-gesture tick.
if (ctx.state === "suspended") ctx.resume().catch(() => {});
let off = startOffset;
if (off >= duration) off = 0;
startSources(off);
playing = true;
rafId = requestAnimationFrame(tick);
}
function pause() {
if (!playing) return;
const t = now();
stopSources();
playing = false;
startOffset = Math.max(0, Math.min(t, duration));
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
function seek(t) {
const clamped = Math.max(0, Math.min(t, duration || 0));
if (playing) {
stopSources();
startSources(clamped);
} else {
startOffset = clamped;
}
onTime?.(clamped);
}
function setGain(name, v) {
const t = tracks.get(name);
if (t) t.gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function setMasterGain(v) {
master.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01);
}
function destroy() {
destroyed = true;
stopSources();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
tracks.clear();
if (stNode) { try { stNode.disconnect(); } catch { /* noop */ } }
if (ownsCtx) ctx.close().catch(() => {});
}
return {
ready,
play,
pause,
seek,
setTime: seek, // alias to match the multitrack interface used by transport.js
isPlaying: () => playing,
getCurrentTime: now,
getDuration: () => duration,
setLoop: (enabled, start, end) => { loop = { enabled, start, end }; },
setPlaybackRate(rate) {
_playbackRate = rate;
if (stNode) {
// Pitch-preserving: update SoundTouch tempo parameter
stNode.parameters.get('tempo').value = rate;
} else {
// Tape-effect fallback
for (const t of tracks.values()) {
if (t.source) t.source.playbackRate.value = rate;
}
}
},
setGain,
setMasterGain,
getAnalyser: (name) => tracks.get(name)?.analyser ?? null,
// Decoded AudioBuffers keyed by stem name — reused by the visuals (overview
// waveforms, mini-waves, VU envelopes, energy bars) so they don't need the
// multitrack to also decode the audio. Map<name, AudioBuffer>.
getBuffers: () => {
const m = new Map();
for (const [name, t] of tracks) m.set(name, t.buffer);
return m;
},
destroy,
audioContext: ctx,
};
}
// Rough decoded-PCM memory estimate (Float32 = 4 bytes/sample/channel) used by the
// caller's guard to fall back to streaming for very long / many-stem tracks.
export function estimateDecodedBytes(durationSec, stemCount, channels = 2, sampleRate = 44100) {
return Math.round(durationSec * stemCount * channels * sampleRate * 4);
}

Some files were not shown because too many files have changed in this diff Show More