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
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
jobs/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
.python-version
|
||||
README.md
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`stemdeckapp/stemdeck`
|
||||
- 原始仓库:https://github.com/stemdeckapp/stemdeck
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -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.
|
||||
@@ -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)}
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.pipeline.runner import run_local_pipeline, run_pipeline
|
||||
|
||||
__all__ = ["run_pipeline", "run_local_pipeline"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
@@ -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}" "$@"
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 875 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": "0.1.0-alpha.1",
|
||||
"arch": "arm64",
|
||||
"runtimeUrl": "",
|
||||
"runtimeSha256": "",
|
||||
"runtimeSize": 0,
|
||||
"archiveName": "StemDeck-runtime-macOS-arm64.tar.zst"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.8 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,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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||