diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e2fe693a..4b905ac9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,6 +12,11 @@ on:
release:
types: [published]
+# No job writes to the repository, so the token gets nothing. Checkout of this
+# public repository and the artifact upload work without it, as in the other
+# workflows.
+permissions: {}
+
env:
UV_LINK_MODE: copy
# Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which
@@ -25,7 +30,7 @@ jobs:
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# diffq (a mandatory transitive dependency of audio-separator, #275) has
# no prebuilt wheel for Python 3.11+ on Linux -- its last release only
# ever shipped cp310 wheels -- so uv sync must compile it from source.
@@ -37,13 +42,20 @@ jobs:
- run: uv run ruff check app/ tests/
- run: uv run ruff format --check app/ tests/
- run: bash -n run.sh
+ # The retry wrapper the e2e installs depend on (#838). Here because this
+ # job runs on every pull request, whatever it changes.
+ - run: bash tests/ci/test_retry_sh.sh
+ # The Docker entrypoint's ownership pass (#994). This container runs as
+ # root, which the mixed-owner and symlink cases need, and sh here is
+ # dash, the same shell as the image.
+ - run: bash tests/ci/test_docker_reown_sh.sh
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# build-essential: see the matching comment in the lint job above.
- run: apt-get update && apt-get install -y --no-install-recommends ffmpeg build-essential
- run: uv sync --frozen --all-extras
@@ -54,49 +66,30 @@ jobs:
container:
image: node:20-alpine
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: for f in static/js/*.js; do node --check "$f"; done
- run: for f in tests/js/*.test.mjs; do node "$f"; done
- # Browser tests for static/js (#339). node --check above is a syntax parse;
- # this drives the real UI against the real backend, including the Tauri code
- # path, which is where #335 hid because it was invisible in a browser.
- # Split three ways, each shard on its own runner (#686). The suite runs
- # serially inside a shard -- workers: 1 in playwright.config -- because it
- # shares one backend and one jobs directory, and several specs change server
- # state. A shard is a separate machine with its own backend, so splitting
- # here adds parallelism without adding sharing. Files stay whole, since
- # fullyParallel is off, so tests that rely on order within a file still get
- # it. Free on a public repository; each shard pays its own setup.
+ # Browser tests for static/js: the steps live in e2e.yml, shared with
+ # e2e-full.yml. Runs the whole suite, and only for a pull request into main,
+ # a push to main or a release. A pull request into any other base (such as
+ # big-release) skips it (#1024): the full run on every push to big-release
+ # and the nightly run (e2e-full.yml) catch a broken spec on the merge commit.
+ # Checks show as "frontend-e2e / frontend-e2e (1)" to "(3)" on those events.
frontend-e2e:
- runs-on: ubuntu-latest
- strategy:
- # One failing shard must not cancel the others: the report is only
- # useful whole.
- fail-fast: false
- matrix:
- shard: [1, 2, 3]
- steps:
- - uses: actions/checkout@v7.0.1
- - uses: actions/setup-node@v7.0.0
- with:
- node-version: "20"
- cache: npm
- - run: curl -LsSf https://astral.sh/uv/install.sh | sh
- - run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- - run: uv sync --frozen --all-extras
- - run: npm ci
- - run: npx playwright install --with-deps chromium
- - run: npx playwright test --shard=${{ matrix.shard }}/3
- - if: failure()
- uses: actions/upload-artifact@v7.0.1
- with:
- # Per shard: a second upload under the same name fails the job.
- name: playwright-report-${{ matrix.shard }}
- path: |
- playwright-report/
- test-results/
- retention-days: 7
+ if: github.event_name != 'pull_request' || github.base_ref == 'main'
+ # A new push to a pull request cancels that pull request's older e2e run.
+ # Anything else gets a group of its own (the run id), so a push to main or
+ # a release is never cancelled and never waits behind another run. This
+ # does not help a runner GitHub never assigns: re-run that job, or
+ # gh run cancel then gh run rerun --failed.
+ concurrency:
+ group: e2e-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+ uses: ./.github/workflows/e2e.yml
+ with:
+ mode: full
+ base-ref: ${{ github.base_ref }}
# The Linux installer only ever runs on a user's machine, so nothing else
# would catch a regression in it. Runs on a real Linux image rather than the
@@ -104,7 +97,7 @@ jobs:
linux-installer:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash -n packaging/linux/install.sh
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils
- run: shellcheck -S warning packaging/linux/install.sh
@@ -115,7 +108,7 @@ jobs:
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: uv tool install bandit
- run: uv tool run bandit -r app/ -ll # fail on medium+ severity
@@ -124,7 +117,7 @@ jobs:
container:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: uv tool install pip-audit
- run: uv pip compile pyproject.toml -o /tmp/requirements.txt
# Ignored CVEs (review when upgrading torch or demucs):
@@ -185,7 +178,26 @@ jobs:
trivy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # Installs Trivy from its GitHub release asset, checked against a pinned
+ # sha256, instead of letting trivy-action call setup-trivy. setup-trivy's
+ # install.sh downloads the binary from get.trivy.dev, so the scan failed
+ # (curl exit 6) as soon as that hostname stopped resolving. Bump the
+ # version and the sha256 together: the sha256 is the Linux-64bit line of
+ # trivy__checksums.txt in the same release.
+ - name: Install Trivy
+ env:
+ TRIVY_VERSION: 0.70.0
+ TRIVY_SHA256: 8b4376d5d6befe5c24d503f10ff136d9e0c49f9127a4279fd110b727929a5aa9
+ run: |
+ tarball="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
+ curl -fsSL -o "$RUNNER_TEMP/$tarball" \
+ "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/$tarball"
+ echo "$TRIVY_SHA256 $RUNNER_TEMP/$tarball" | sha256sum --check --strict
+ mkdir -p "$RUNNER_TEMP/trivy-bin"
+ tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP/trivy-bin" trivy
+ echo "$RUNNER_TEMP/trivy-bin" >> "$GITHUB_PATH"
+ "$RUNNER_TEMP/trivy-bin/trivy" --version
# 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
@@ -202,6 +214,7 @@ jobs:
ignore-unfixed: true
trivyignores: .trivyignore
skip-dirs: .venv,jobs
+ skip-setup-trivy: true
# Dedicated Dockerfile + compose static analysis (Trivy's IaC linter).
- name: trivy config
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
@@ -210,3 +223,4 @@ jobs:
scan-ref: build/
severity: HIGH,CRITICAL
exit-code: '1'
+ skip-setup-trivy: true
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 60e5a60b..939a2bdc 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -5,7 +5,15 @@ name: Docker Publish
# (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/).
+# Unraid template in templates/stemdeck.xml).
+#
+# A second image carries the ROCm build of torch for AMD GPUs (#825), with the
+# same tags plus a -rocm suffix (:rocm where the default image gets :latest).
+# It is a separate image rather than the default because the ROCm runtime is
+# several GB more, which every CPU and NVIDIA user would otherwise pull for
+# nothing. Each image is its own matrix leg: its own runner, disk and timeout,
+# built in parallel, and fail-fast is off, so a ROCm failure can never stop or
+# delay the default image.
on:
# Every merge to main publishes a rolling :edge image. The version is derived
@@ -41,8 +49,28 @@ env:
jobs:
build-and-push:
+ name: build-and-push (${{ matrix.image }})
runs-on: ubuntu-latest
- timeout-minutes: 60
+ timeout-minutes: ${{ matrix.timeout }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - image: default
+ edge: edge
+ suffix: ""
+ latest: latest
+ torch_index: ""
+ timeout: 60
+ # Not measured yet: the ROCm wheels are about 3.6 GB on top of the
+ # CUDA stack the project install pulls first (torch<2.7 in
+ # pyproject.toml), so the leg gets twice the default's time.
+ - image: rocm
+ edge: edge-rocm
+ suffix: "-rocm"
+ latest: rocm
+ torch_index: https://download.pytorch.org/whl/rocm6.4
+ timeout: 120
permissions:
contents: read
packages: write
@@ -76,14 +104,22 @@ jobs:
with:
images: ${{ env.IMAGE }}
tags: |
- type=raw,value=edge,enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version == '') }}
- type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version != '') }}
- type=raw,value=latest,enable=${{ github.event_name == 'release' && (github.event.action == 'released' || (github.event.action == 'published' && !github.event.release.prerelease)) }}
+ type=raw,value=${{ matrix.edge }},enable=${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version == '') }}
+ type=raw,value=${{ steps.ver.outputs.value }}${{ matrix.suffix }},enable=${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.version != '') }}
+ type=raw,value=${{ matrix.latest }},enable=${{ github.event_name == 'release' && (github.event.action == 'released' || (github.event.action == 'published' && !github.event.release.prerelease)) }}
+
+ # The ROCm wheels unpack to several GB on top of the CUDA stack the
+ # project install pulls first, more than a hosted runner has free.
+ - name: free disk space for the ROCm build
+ if: matrix.image == 'rocm'
+ run: |
+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
+ df -h /
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: log in to GHCR
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -94,11 +130,13 @@ jobs:
with:
context: .
file: build/Dockerfile
- # Unraid is x86_64; arm64 has no CUDA torch and is not a target.
+ # Unraid is x86_64; arm64 has no CUDA or ROCm torch and is not a target.
platforms: linux/amd64
push: true
+ # Empty for the default image, which keeps the CUDA build.
build-args: |
VERSION=${{ steps.ver.outputs.value }}
+ TORCH_INDEX_URL=${{ matrix.torch_index }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
diff --git a/.github/workflows/e2e-full.yml b/.github/workflows/e2e-full.yml
new file mode 100644
index 00000000..c714dc64
--- /dev/null
+++ b/.github/workflows/e2e-full.yml
@@ -0,0 +1,46 @@
+# The whole Playwright suite on big-release, the integration branch. A pull
+# request into big-release does not run Playwright (ci.yml), so this is where a
+# broken spec shows up: within minutes of the merge that broke it, on that
+# merge's commit. It never blocks a pull request.
+#
+# Triggers:
+# - every push to big-release (each merged pull request);
+# - nightly, on big-release;
+# - by hand, on any branch: gh workflow run e2e-full.yml --repo stemdeckapp/stemdeck --ref
+# (or -f ref= to test another ref with this branch's workflow).
+#
+# The schedule only fires from the default branch: GitHub reads this file, and
+# the e2e.yml it calls, from main. Until this file reaches main there is no
+# nightly run; pushes to big-release and manual runs work before that.
+
+name: e2e-full
+
+on:
+ push:
+ branches: [big-release]
+ schedule:
+ # 04:17 UTC, off the top of the hour when hosted runners are busiest.
+ - cron: "17 4 * * *"
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Branch, tag or SHA to test (empty: the branch this was run on)"
+ type: string
+ default: ""
+
+permissions: {}
+
+# No concurrency group on purpose: a push to big-release, the nightly and a
+# manual run are each a record of one commit, and none may cancel another.
+
+jobs:
+ full-suite:
+ # Forks inherit the schedule; only this repository runs it.
+ if: github.repository == 'stemdeckapp/stemdeck'
+ uses: ./.github/workflows/e2e.yml
+ with:
+ mode: full
+ # A scheduled run checks out the default branch (main) unless told
+ # otherwise, so the nightly names big-release explicitly. A push tests
+ # its own commit; a manual run tests the ref input, else its own branch.
+ checkout-ref: ${{ github.event_name == 'schedule' && 'big-release' || inputs.ref || github.sha }}
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 00000000..e4ee72f1
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,271 @@
+# Browser tests for static/js (#339), as one reusable workflow. ci.yml calls it
+# for pull requests into main, pushes to main and releases; e2e-full.yml calls it for every push to
+# big-release, the nightly run and a manual run. The steps live only here.
+#
+# This drives the real UI against the real backend, including the Tauri code
+# path, which is where #335 hid because it was invisible in a browser.
+# Split three ways, each shard on its own runner (#686). The suite runs
+# serially inside a shard -- workers: 1 in playwright.config -- because it
+# shares one backend and one jobs directory, and several specs change server
+# state. A shard is a separate machine with its own backend, so splitting
+# here adds parallelism without adding sharing. Files stay whole, since
+# fullyParallel is off, so tests that rely on order within a file still get
+# it. Free on a public repository; each shard pays its own setup.
+
+name: e2e
+
+on:
+ workflow_call:
+ inputs:
+ mode:
+ description: "full runs every spec (every caller uses it today); scoped runs the set scripts/ci/select-e2e.mjs picks for the diff against base-ref"
+ type: string
+ required: true
+ base-ref:
+ description: "Branch a scoped run diffs against (the pull request's base)"
+ type: string
+ default: ""
+ checkout-ref:
+ description: "Ref to test; empty means the commit of the triggering event"
+ type: string
+ default: ""
+
+# The caller's token already has nothing; this keeps it that way if a new
+# caller grants more. Checkout of this public repository and the artifact
+# upload work without it.
+permissions: {}
+
+env:
+ UV_LINK_MODE: copy
+ # See the matching comment in ci.yml (#169).
+ SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
+
+jobs:
+ frontend-e2e:
+ # Pinned, not ubuntu-latest: that label moves to Ubuntu 26 from
+ # 2026-10-19 (https://github.com/actions/runner-images/issues/14748), and
+ # the move is gradual, so two shards of one run could land on different
+ # images. The stalled runs in #838 were already on ubuntu-24.04; the cause
+ # there was the mirror, and the pin keeps the image from becoming a
+ # second variable. Revisit once that migration has finished and the suite
+ # has passed on 26.04; the cache keys below name the image, so moving it
+ # starts from clean caches.
+ runs-on: ubuntu-24.04
+ # A full shard takes about 10 minutes, setup included. This bounds a hung
+ # run; it does not start a runner that GitHub never assigns, since queue
+ # time does not count toward it. The install steps below have their own,
+ # shorter limits, so a stalled install fails in minutes and names itself.
+ timeout-minutes: 30
+ strategy:
+ # One failing shard must not cancel the others: the report is only
+ # useful whole.
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3]
+ env:
+ SHARD: ${{ matrix.shard }}
+ # ffmpeg for the backend under test: a static build from BtbN's GitHub
+ # releases, the builder the Windows desktop app already downloads from.
+ # A month-end autobuild, because BtbN keeps those for about two years
+ # and daily ones for about two weeks. The SHA256 matches both GitHub's
+ # asset digest and BtbN's checksums.sha256 for this release. Bump all
+ # three together.
+ FFMPEG_RELEASE: autobuild-2026-09-30-13-08
+ FFMPEG_ASSET: ffmpeg-n8.1.3-9-g29e619e767-linux64-gpl-8.1.tar.xz
+ FFMPEG_SHA256: 97ce978979194b5cf7e06a5e68020dbdaa7a4f3294c5452b6a1bc347100dbb79
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ inputs.checkout-ref }}
+ # Full history, so the base branch is there to diff against.
+ fetch-depth: 0
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: "20"
+ cache: npm
+ # Empty output means everything. The inputs go through env, never into
+ # the script text.
+ - name: select specs
+ env:
+ MODE: ${{ inputs.mode }}
+ BASE_REF: ${{ inputs.base-ref }}
+ run: node scripts/ci/select-e2e.mjs --mode "$MODE" --base "$BASE_REF" > "$RUNNER_TEMP/e2e-specs.txt"
+ # Each setup step records its own duration for the job summary (#838).
+ - name: install uv
+ run: |
+ bash scripts/ci/setup-timing.sh begin "uv install"
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ bash scripts/ci/setup-timing.sh end "uv install"
+ - name: uv sync
+ run: |
+ bash scripts/ci/setup-timing.sh begin "uv sync"
+ uv sync --frozen --all-extras
+ bash scripts/ci/setup-timing.sh end "uv sync"
+ - name: npm ci
+ run: |
+ bash scripts/ci/setup-timing.sh begin "npm ci"
+ npm ci
+ bash scripts/ci/setup-timing.sh end "npm ci"
+ # Cache keys: the Playwright version the lockfile resolves (it decides
+ # the browser build and the system packages), and the runner image
+ # version (it decides which .deb versions apt wants).
+ - name: cache keys
+ id: keys
+ run: |
+ pw="$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version")"
+ echo "playwright=$pw" >> "$GITHUB_OUTPUT"
+ echo "image=${ImageOS:-unknown}-${ImageVersion:-unknown}" >> "$GITHUB_OUTPUT"
+ - name: restore Playwright browsers
+ id: browsers-cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-browsers-ubuntu-24.04-${{ steps.keys.outputs.playwright }}
+ # The browser download alone, from Playwright's CDN: about 10 s, and
+ # nothing on a cache hit. The system packages are a separate step, so a
+ # cached browser is not held up by apt. 3 attempts of at most 150 s plus
+ # 30 s of pauses fit inside the step's 10 minutes.
+ - name: Playwright browsers
+ timeout-minutes: 10
+ env:
+ CACHE: ${{ steps.browsers-cache.outputs.cache-hit == 'true' && 'hit' || 'miss' }}
+ run: |
+ bash scripts/ci/setup-timing.sh begin "Playwright browsers"
+ bash scripts/ci/retry.sh --timeout 150 -- npx playwright install chromium
+ bash scripts/ci/setup-timing.sh end "Playwright browsers" "$CACHE"
+ # One shard saves, so the other two do not race it for the same key.
+ # Saved here rather than at the end of the job, so a failing spec does
+ # not cost the next run its cache.
+ - if: matrix.shard == 1 && steps.browsers-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-browsers-ubuntu-24.04-${{ steps.keys.outputs.playwright }}
+ # The image already has Chromium's libraries. What install-deps still
+ # fetches is about 26 MB of fonts and package upgrades, and that is
+ # where #838's 12-minute installs went (29 kB/s from the Azure Ubuntu
+ # mirror). The .debs are cached, so most runs only fetch the package
+ # index. An older image's .debs are a fine start: apt uses the ones that
+ # still match and downloads the rest.
+ - name: restore apt packages
+ id: debs-cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/e2e-apt-debs
+ key: e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}-${{ steps.keys.outputs.image }}
+ restore-keys: |
+ e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}-
+ # apt's robustness options go in a config file so they also reach the
+ # apt-get that Playwright runs: retry a failed file, drop a connection
+ # silent for 20 s instead of waiting on it, wait for the dpkg lock
+ # rather than fail, and keep the .debs for the cache. retry.sh runs
+ # under sudo so its timeout can kill apt itself, and apt resumes partial
+ # downloads, so a retry does not start from zero. 3 attempts of at most
+ # 170 s plus 30 s of pauses fit inside the step's 10 minutes.
+ # The image's apt sources go through /etc/apt/apt-mirrors.txt: Azure's
+ # mirror first, then archive.ubuntu.com, then security.ubuntu.com. apt
+ # only falls back on an error, never on a trickle, and #838's own CI saw
+ # Azure serve the font packages at about 40 kB/s for nine minutes while
+ # it served the package index at 1.5 MB/s. So a retry drops Azure and
+ # starts on archive.ubuntu.com.
+ - name: Playwright system dependencies
+ timeout-minutes: 10
+ env:
+ CACHE: ${{ steps.debs-cache.outputs.cache-hit == 'true' && 'hit' || (steps.debs-cache.outputs.cache-matched-key != '' && 'partial' || 'miss') }}
+ run: |
+ bash scripts/ci/setup-timing.sh begin "Playwright system deps"
+ printf '%s\n' \
+ 'Acquire::Retries "3";' \
+ 'Acquire::http::Timeout "20";' \
+ 'Acquire::https::Timeout "20";' \
+ 'DPkg::Lock::Timeout "60";' \
+ 'APT::Keep-Downloaded-Packages "true";' \
+ | sudo tee /etc/apt/apt.conf.d/80-e2e-robust > /dev/null
+ debs="$HOME/.cache/e2e-apt-debs"
+ mkdir -p "$debs"
+ find "$debs" -maxdepth 1 -name '*.deb' -exec sudo cp -t /var/cache/apt/archives/ {} +
+ cat /etc/apt/apt-mirrors.txt 2>/dev/null || true
+ sudo env "PATH=$PATH" "GITHUB_ACTIONS=${GITHUB_ACTIONS:-}" bash scripts/ci/retry.sh --timeout 170 -- bash -c '
+ if [ "$RETRY_ATTEMPT" -gt 1 ] && grep -q "azure\.archive\.ubuntu\.com" /etc/apt/apt-mirrors.txt 2>/dev/null; then
+ echo "retry: attempt $RETRY_ATTEMPT drops the Azure Ubuntu mirror"
+ sed -i "/azure\.archive\.ubuntu\.com/d" /etc/apt/apt-mirrors.txt
+ fi
+ exec node_modules/.bin/playwright install-deps chromium'
+ find /var/cache/apt/archives -maxdepth 1 -name '*.deb' -exec cp -t "$debs" {} +
+ echo "cached .debs: $(find "$debs" -name '*.deb' | wc -l), $(du -sh "$debs" | cut -f1)"
+ bash scripts/ci/setup-timing.sh end "Playwright system deps" "$CACHE"
+ - if: matrix.shard == 1 && steps.debs-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/e2e-apt-debs
+ key: e2e-apt-debs-ubuntu-24.04-${{ steps.keys.outputs.playwright }}-${{ steps.keys.outputs.image }}
+ # The backend under test is real, and finishing a recorded take renders
+ # it with ffmpeg (app/pipeline/take_render.py). The runner image has no
+ # ffmpeg, so without this the take specs get a 500 and time out. This
+ # was apt's ffmpeg, 63 MB from the same mirror, the step #838's shards
+ # were killed in. The archive is checked against its pin on every run,
+ # cache hit or not, before anything in it is unpacked.
+ - name: restore ffmpeg
+ id: ffmpeg-cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/e2e-ffmpeg
+ key: e2e-ffmpeg-${{ env.FFMPEG_SHA256 }}
+ - name: ffmpeg
+ timeout-minutes: 10
+ env:
+ CACHE: ${{ steps.ffmpeg-cache.outputs.cache-hit == 'true' && 'hit' || 'miss' }}
+ run: |
+ bash scripts/ci/setup-timing.sh begin "ffmpeg"
+ dir="$HOME/.cache/e2e-ffmpeg"
+ archive="$dir/$FFMPEG_ASSET"
+ mkdir -p "$dir"
+ if ! echo "$FFMPEG_SHA256 $archive" | sha256sum --check --status 2>/dev/null; then
+ rm -f "$archive"
+ bash scripts/ci/retry.sh --timeout 150 -- \
+ curl -fsSL --connect-timeout 20 -o "$archive" \
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/$FFMPEG_RELEASE/$FFMPEG_ASSET"
+ if ! echo "$FFMPEG_SHA256 $archive" | sha256sum --check; then
+ echo "::error::ffmpeg archive $FFMPEG_ASSET does not match its pinned SHA256; not using it"
+ rm -f "$archive"
+ exit 1
+ fi
+ fi
+ bin="$RUNNER_TEMP/ffmpeg"
+ mkdir -p "$bin"
+ tar -xJf "$archive" -C "$bin" --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe'
+ echo "$bin" >> "$GITHUB_PATH"
+ "$bin/ffmpeg" -hide_banner -version | head -1
+ # The export specs need all four, and ogg needs Vorbis. Fail here,
+ # by name, rather than as a 500 in a spec minutes later.
+ "$bin/ffmpeg" -hide_banner -encoders | grep -E "libvorbis|libmp3lame" || true
+ for enc in libvorbis libmp3lame aac flac; do
+ if ! "$bin/ffmpeg" -hide_banner -encoders | grep -qE "^ A[^ ]* +$enc "; then
+ echo "::error::ffmpeg $FFMPEG_ASSET has no $enc encoder; the export specs need it"
+ exit 1
+ fi
+ done
+ bash scripts/ci/setup-timing.sh end "ffmpeg" "$CACHE"
+ - if: matrix.shard == 1 && steps.ffmpeg-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/e2e-ffmpeg
+ key: e2e-ffmpeg-${{ env.FFMPEG_SHA256 }}
+ # Runs whatever happened above, so a stalled or failed install still
+ # shows on the run page, with how long it had been running.
+ - name: setup timing summary
+ if: always()
+ run: bash scripts/ci/setup-timing.sh summary
+ # A reduced set can leave a shard with nothing to run, which must pass.
+ # The spec list is unquoted on purpose: one argument per path.
+ - run: npx playwright test --shard=${{ matrix.shard }}/3 --pass-with-no-tests $(cat "$RUNNER_TEMP/e2e-specs.txt")
+ - if: failure()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ # Per shard: a second upload under the same name fails the job.
+ name: playwright-report-${{ matrix.shard }}
+ path: |
+ playwright-report/
+ test-results/
+ retention-days: 7
diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml
index 941cd018..d66a45bc 100644
--- a/.github/workflows/linux-release.yml
+++ b/.github/workflows/linux-release.yml
@@ -3,24 +3,43 @@ 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.
+ # Release candidate build (#1027): builds the commit this run is started on
+ # and uploads to an existing draft pre-release with this tag. The guard job
+ # refuses any tag that is not exactly one draft pre-release targeting that
+ # commit, so a published release can never be overwritten from here.
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"
+ tag:
+ description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1"
+ required: true
+ type: string
permissions: {}
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ # A manual run shares the group of the release it builds into. Only a
+ # release event cancels what is in progress: publishing a draft cancels a
+ # candidate build still running for it, while a manual run never cancels a
+ # release build. The group comes from the raw input and applies before the
+ # guard, so a manual run typed with a published tag waits behind that
+ # release's build, and its guard then refuses. On a release event inputs.tag
+ # is empty and the group is the same as before.
+ group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'release' }}
jobs:
+ guard:
+ if: github.event_name == 'workflow_dispatch'
+ uses: ./.github/workflows/release-candidate-guard.yml
+ permissions:
+ contents: write
+ with:
+ tag: ${{ inputs.tag }}
+
build-and-upload:
+ needs: guard
+ # A release event skips the guard; a manual run builds only if it passed.
+ if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }}
# Runner must be linux/x64 with rustup, Node.js, Docker, and sudo apt access.
runs-on: [self-hosted, linux, x64]
timeout-minutes: 90
@@ -77,17 +96,13 @@ jobs:
- 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 }}
+ # which is only injected by runner >= 2.290, so it is empty on older
+ # self-hosted runners. A manual run takes the tag the guard
+ # validated; if the guard set nothing it is empty and this step stops.
+ REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }}
run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- VERSION="${DISPATCH_VERSION#v}"
- else
- VERSION="${REF_NAME#v}"
- fi
+ VERSION="${REF_NAME#v}"
if [ -z "$VERSION" ]; then
echo "Could not determine a version" >&2
exit 1
@@ -95,7 +110,6 @@ jobs:
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"
@@ -144,7 +158,6 @@ jobs:
# missing updater asset published a release that looked fine and went
# green, and the in-app updater then 404'd for every installed user.
- name: verify every asset exists
- if: github.event_name == 'release'
run: |
for f in \
dist/StemDeck-Linux-x64.tar.gz \
@@ -157,9 +170,92 @@ jobs:
test -f "$f" || { echo "missing release asset: $f" >&2; exit 1; }
done
+ # The guard ran once, at the start of this run, and "Re-run failed jobs"
+ # reuses its result. Check again on every attempt, right before the
+ # upload, that the guarded release is still a draft pre-release with the
+ # guarded tag, pointing at the commit this run built. Without this, a
+ # re-run after publishing would overwrite the published assets, and a
+ # re-run of an older run would overwrite a retargeted draft's newer ones.
+ - name: check the release is still the guarded draft
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ API_URL: ${{ github.api_url }}
+ REPO: ${{ github.repository }}
+ RELEASE_ID: ${{ needs.guard.outputs.release_id }}
+ TAG: ${{ needs.guard.outputs.tag }}
+ RUN_SHA: ${{ github.sha }}
+ run: |
+ node - <<'NODE'
+ // Throw rather than process.exit(): exiting with a fetch socket still
+ // open aborts Node on Windows. The catch below sets the exit code.
+ const stop = (why) => {
+ throw new Error(why);
+ };
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+ (async () => {
+ const { GH_TOKEN, API_URL, REPO, RELEASE_ID, TAG, RUN_SHA } = process.env;
+ if (!/^[0-9]+$/.test(RELEASE_ID || '') || !TAG || !/^[0-9a-f]{40}$/.test(RUN_SHA || '')) {
+ stop('the guard set no release id or tag, or the run has no commit SHA.');
+ }
+ if (typeof fetch !== 'function') stop('this check needs Node.js 18 or newer on the runner.');
+ const get = async (path) => {
+ const res = await fetch(`${API_URL}/repos/${REPO}/${path}`, {
+ headers: {
+ Authorization: `Bearer ${GH_TOKEN}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ });
+ if (!res.ok) stop(`${path} could not be read (HTTP ${res.status}).`);
+ return res.json();
+ };
+
+ const rel = await get(`releases/${RELEASE_ID}`);
+ if (
+ !isObject(rel) ||
+ typeof rel.tag_name !== 'string' ||
+ typeof rel.draft !== 'boolean' ||
+ typeof rel.prerelease !== 'boolean' ||
+ typeof rel.target_commitish !== 'string'
+ ) {
+ stop(`release ${RELEASE_ID} did not come back as a release object.`);
+ }
+ console.log(
+ `Release ${RELEASE_ID}: tag=${rel.tag_name} draft=${rel.draft} prerelease=${rel.prerelease} target_commitish=${rel.target_commitish}`,
+ );
+ if (rel.tag_name !== TAG) stop(`release ${RELEASE_ID} now has the tag ${rel.tag_name}, not ${TAG}.`);
+ if (rel.draft !== true) stop(`release ${TAG} is no longer a draft; its own release build owns its assets.`);
+ if (rel.prerelease !== true) stop(`release ${TAG} is no longer marked pre-release.`);
+
+ // The guard's commit check, repeated: an existing tag's commit,
+ // otherwise the draft's target, must be the commit this run built.
+ const refs = await get(`git/matching-refs/tags/${TAG}`);
+ if (!Array.isArray(refs)) stop(`the tag lookup for ${TAG} did not come back as a list.`);
+ let expected;
+ let source;
+ if (refs.some((r) => isObject(r) && r.ref === `refs/tags/${TAG}`)) {
+ const commit = await get(`commits/refs/tags/${TAG}`);
+ if (!isObject(commit) || typeof commit.sha !== 'string') stop(`the tag ${TAG} did not resolve to a commit.`);
+ expected = commit.sha;
+ source = `existing tag ${TAG}`;
+ } else {
+ expected = rel.target_commitish;
+ source = 'target_commitish';
+ }
+ console.log(`Commit this run built: ${RUN_SHA}`);
+ console.log(`Commit the release points at (${source}): ${expected}`);
+ if (expected !== RUN_SHA) {
+ stop(`this run built ${RUN_SHA} but ${TAG} now points at ${expected} (${source}). Start a new run instead of re-running this one.`);
+ }
+ console.log(`Decision: UPLOAD to draft ${TAG} (release ${RELEASE_ID}).`);
+ })().catch((err) => {
+ console.log(`::error::Refusing to upload: ${err.message}`);
+ process.exitCode = 1;
+ });
+ NODE
+
- 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@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
# Carry the release's own flag. The action defaults `prerelease` to
@@ -167,7 +263,12 @@ jobs:
# pre-release to the latest release -- which also fires `released`,
# pushing :latest to GHCR, and makes the in-app updater offer a build
# that was never verified.
- prerelease: ${{ github.event.release.prerelease }}
+ prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }}
+ # A manual run uploads to the draft named by the guarded tag. Without
+ # draft: true the action publishes the draft after uploading. On a
+ # release event both are empty and the action keeps its defaults.
+ tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }}
+ draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }}
fail_on_unmatched_files: true
files: |
dist/StemDeck-Linux-x64.tar.gz
diff --git a/.github/workflows/macos-check.yml b/.github/workflows/macos-check.yml
index 57f62d0e..06ac0132 100644
--- a/.github/workflows/macos-check.yml
+++ b/.github/workflows/macos-check.yml
@@ -1,7 +1,6 @@
name: macOS Rust Check
-# Manual only -- not on every PR/push, to keep the self-hosted runner's load
-# and cost unchanged from today. Exists because ci.yml (which does run on
+# Manual, plus PRs that touch the Rust (see below). Exists because ci.yml (which does run on
# every PR) is 100% ubuntu-latest, and a Linux runner cannot type-check code
# behind #[cfg(target_os = "macos")] at all -- that code is stripped before
# semantic analysis even starts, not just skipped at test time. This is the
@@ -10,8 +9,7 @@ on:
workflow_dispatch:
# Also on PRs that actually touch the Rust, so a desktop-shell change cannot
# merge without a real compiler pass on this platform. Scoped by path so the
- # self-hosted runner's load is unchanged for the majority of PRs, which do not
- # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every
+ # majority of PRs, which do not go near src-tauri, skip it. (#421 added ~600 lines of mostly cfg-gated Rust and every
# CI check passed without compiling any of it.)
pull_request:
paths:
@@ -26,25 +24,19 @@ concurrency:
jobs:
check:
- # Never run a fork's code on the self-hosted runner. cargo build/clippy/test
- # all execute whatever the PR supplies -- build.rs, proc-macro crates, a
- # swapped Cargo.toml dependency, the test bodies themselves -- and this
- # runner is the same machine that builds, signs and uploads every macOS
- # release. Nothing here cleans the workspace, so an implant in ~/.cargo,
- # ~/.rustup or the persistent _work tree would survive into the next
- # release. permissions: {} limits the token, not code execution.
- #
- # GitHub's public-repo default only gates *first-time* contributors, so one
- # trivial merged PR is enough to unlock this for a later one. Fork PRs get a
- # maintainer-triggered workflow_dispatch run instead.
+ # Fork PRs get a maintainer-triggered workflow_dispatch run instead. This
+ # runner is GitHub-hosted and disposable, so the guard is no longer needed
+ # for safety; it is kept so fork PRs do not start compiles on their own.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
- # Runner must be darwin/arm64 with Xcode CLT and rustup (same requirements
- # as macos-release.yml, which this intentionally does not replace -- this
- # only builds/checks, never signs, packages, or uploads anything).
- runs-on: [self-hosted, macOS, ARM64]
+ # macos-latest is Apple Silicon (arm64) and ships Xcode CLT and rustup. This
+ # intentionally does not replace macos-release.yml -- this only builds/checks,
+ # never signs, packages, or uploads anything.
+ runs-on: macos-latest
timeout-minutes: 30
+ env:
+ CARGO_INCREMENTAL: 0
defaults:
run:
shell: bash
@@ -52,6 +44,19 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # Compiled dependencies are most of this job's time. Keyed on Cargo.lock, so a
+ # dependency change starts a fresh cache and the closest older one seeds it.
+ - name: cache cargo
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ desktop/src-tauri/target
+ key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/src-tauri/Cargo.lock') }}
+ restore-keys: |
+ cargo-${{ runner.os }}-${{ runner.arch }}-
+
- name: cargo fmt --check
run: cargo fmt --check
diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml
index 3b552720..db73cdfe 100644
--- a/.github/workflows/macos-release.yml
+++ b/.github/workflows/macos-release.yml
@@ -3,18 +3,48 @@ name: macOS Release
on:
release:
types: [published]
+ # Release candidate build (#1027): builds the commit this run is started on
+ # and uploads to an existing draft pre-release with this tag. The guard job
+ # refuses any tag that is not exactly one draft pre-release targeting that
+ # commit, so a published release can never be overwritten from here. The
+ # signing check below still refuses to build without every secret.
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1"
+ required: true
+ type: string
permissions: {}
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ # A manual run shares the group of the release it builds into. Only a
+ # release event cancels what is in progress: publishing a draft cancels a
+ # candidate build still running for it, while a manual run never cancels a
+ # release build. The group comes from the raw input and applies before the
+ # guard, so a manual run typed with a published tag waits behind that
+ # release's build, and its guard then refuses. On a release event inputs.tag
+ # is empty and the group is the same as before.
+ group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'release' }}
jobs:
+ guard:
+ if: github.event_name == 'workflow_dispatch'
+ uses: ./.github/workflows/release-candidate-guard.yml
+ permissions:
+ contents: write
+ with:
+ tag: ${{ inputs.tag }}
+
build-and-upload:
+ needs: guard
+ # A release event skips the guard; a manual run builds only if it passed.
+ if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }}
# Runner must be darwin/arm64 with Rosetta 2, Xcode CLT, Homebrew, rustup, and zstd.
runs-on: [self-hosted, macOS, ARM64]
- timeout-minutes: 120
+ # Four notarizations (app and DMG, for arm64 and x64) can each be slow.
+ timeout-minutes: 360
permissions:
contents: write
defaults:
@@ -29,9 +59,10 @@ jobs:
- 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 }}
+ # which is only injected by runner >= 2.290, so it is empty on older
+ # self-hosted runners. A manual run takes the tag the guard
+ # validated; if the guard set nothing it is empty and this step stops.
+ REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }}
run: |
if [ -z "${REF_NAME:-}" ]; then
echo "REF_NAME is not set" >&2
@@ -41,15 +72,44 @@ jobs:
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"
+ # A release must never ship unsigned (#857): stop here if any signing or
+ # notarization secret is missing, before spending an hour on the build.
+ - name: prepare signing credentials
+ env:
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
+ APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
+ run: |
+ missing=0
+ for name in APPLE_SIGNING_IDENTITY APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_P8; do
+ if [ -z "${!name:-}" ]; then
+ echo "::error::Secret $name is empty. Refusing to build an unsigned macOS release."
+ missing=1
+ fi
+ done
+ [ "$missing" -eq 0 ] || exit 1
+ identities="$(security find-identity -v -p codesigning)"
+ if ! grep -qF "$APPLE_SIGNING_IDENTITY" <<<"$identities"; then
+ echo "::error::The signing identity is not in this runner's keychain."
+ exit 1
+ fi
+ key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
+ (umask 077 && printf '%s\n' "$APPLE_API_KEY_P8" > "$key_path")
+ chmod 600 "$key_path"
+ echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"
+
- name: build macOS arm64
env:
SSL_CERT_FILE: /etc/ssl/cert.pem
REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
command -v zstd
command -v uv >/dev/null 2>&1 || brew install uv
@@ -64,6 +124,9 @@ jobs:
env:
SSL_CERT_FILE: /etc/ssl/cert.pem
REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem
+ APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+ APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+ APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
command -v zstd
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
@@ -111,11 +174,108 @@ jobs:
test -L "$mountpoint/Applications"
test -f "$mountpoint/README-macOS.txt"
test -f "$mountpoint/THIRD_PARTY_NOTICES.txt"
+ # Gatekeeper gate (#857): the shipped app must be sealed, notarized
+ # and stapled, and so must the DMG around it.
+ codesign --verify --deep --strict --verbose=2 "$mountpoint/StemDeck.app"
+ assessment="$(spctl -a -vvv -t exec "$mountpoint/StemDeck.app" 2>&1)" || true
+ echo "$assessment"
+ if ! grep -q ': accepted' <<<"$assessment" ||
+ ! grep -q 'source=Notarized Developer ID' <<<"$assessment"; then
+ echo "$arch StemDeck.app is not accepted as a notarized Developer ID app." >&2
+ exit 1
+ fi
+ xcrun stapler validate "$mountpoint/StemDeck.app"
hdiutil detach "$mountpoint"
rmdir "$mountpoint"
trap - EXIT
+ xcrun stapler validate ".build/macos-dist/StemDeck-macOS-$arch.dmg"
done
+ # The guard ran once, at the start of this run, and "Re-run failed jobs"
+ # reuses its result. Check again on every attempt, right before the
+ # upload, that the guarded release is still a draft pre-release with the
+ # guarded tag, pointing at the commit this run built. Without this, a
+ # re-run after publishing would overwrite the published assets, and a
+ # re-run of an older run would overwrite a retargeted draft's newer ones.
+ - name: check the release is still the guarded draft
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ API_URL: ${{ github.api_url }}
+ REPO: ${{ github.repository }}
+ RELEASE_ID: ${{ needs.guard.outputs.release_id }}
+ TAG: ${{ needs.guard.outputs.tag }}
+ RUN_SHA: ${{ github.sha }}
+ run: |
+ node - <<'NODE'
+ // Throw rather than process.exit(): exiting with a fetch socket still
+ // open aborts Node on Windows. The catch below sets the exit code.
+ const stop = (why) => {
+ throw new Error(why);
+ };
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+ (async () => {
+ const { GH_TOKEN, API_URL, REPO, RELEASE_ID, TAG, RUN_SHA } = process.env;
+ if (!/^[0-9]+$/.test(RELEASE_ID || '') || !TAG || !/^[0-9a-f]{40}$/.test(RUN_SHA || '')) {
+ stop('the guard set no release id or tag, or the run has no commit SHA.');
+ }
+ if (typeof fetch !== 'function') stop('this check needs Node.js 18 or newer on the runner.');
+ const get = async (path) => {
+ const res = await fetch(`${API_URL}/repos/${REPO}/${path}`, {
+ headers: {
+ Authorization: `Bearer ${GH_TOKEN}`,
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ });
+ if (!res.ok) stop(`${path} could not be read (HTTP ${res.status}).`);
+ return res.json();
+ };
+
+ const rel = await get(`releases/${RELEASE_ID}`);
+ if (
+ !isObject(rel) ||
+ typeof rel.tag_name !== 'string' ||
+ typeof rel.draft !== 'boolean' ||
+ typeof rel.prerelease !== 'boolean' ||
+ typeof rel.target_commitish !== 'string'
+ ) {
+ stop(`release ${RELEASE_ID} did not come back as a release object.`);
+ }
+ console.log(
+ `Release ${RELEASE_ID}: tag=${rel.tag_name} draft=${rel.draft} prerelease=${rel.prerelease} target_commitish=${rel.target_commitish}`,
+ );
+ if (rel.tag_name !== TAG) stop(`release ${RELEASE_ID} now has the tag ${rel.tag_name}, not ${TAG}.`);
+ if (rel.draft !== true) stop(`release ${TAG} is no longer a draft; its own release build owns its assets.`);
+ if (rel.prerelease !== true) stop(`release ${TAG} is no longer marked pre-release.`);
+
+ // The guard's commit check, repeated: an existing tag's commit,
+ // otherwise the draft's target, must be the commit this run built.
+ const refs = await get(`git/matching-refs/tags/${TAG}`);
+ if (!Array.isArray(refs)) stop(`the tag lookup for ${TAG} did not come back as a list.`);
+ let expected;
+ let source;
+ if (refs.some((r) => isObject(r) && r.ref === `refs/tags/${TAG}`)) {
+ const commit = await get(`commits/refs/tags/${TAG}`);
+ if (!isObject(commit) || typeof commit.sha !== 'string') stop(`the tag ${TAG} did not resolve to a commit.`);
+ expected = commit.sha;
+ source = `existing tag ${TAG}`;
+ } else {
+ expected = rel.target_commitish;
+ source = 'target_commitish';
+ }
+ console.log(`Commit this run built: ${RUN_SHA}`);
+ console.log(`Commit the release points at (${source}): ${expected}`);
+ if (expected !== RUN_SHA) {
+ stop(`this run built ${RUN_SHA} but ${TAG} now points at ${expected} (${source}). Start a new run instead of re-running this one.`);
+ }
+ console.log(`Decision: UPLOAD to draft ${TAG} (release ${RELEASE_ID}).`);
+ })().catch((err) => {
+ console.log(`::error::Refusing to upload: ${err.message}`);
+ process.exitCode = 1;
+ });
+ NODE
+
- name: upload artifacts
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
@@ -124,7 +284,12 @@ jobs:
# pre-release to the latest release -- which also fires `released`,
# pushing :latest to GHCR, and makes the in-app updater offer a build
# that was never verified.
- prerelease: ${{ github.event.release.prerelease }}
+ prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }}
+ # A manual run uploads to the draft named by the guarded tag. Without
+ # draft: true the action publishes the draft after uploading. On a
+ # release event both are empty and the action keeps its defaults.
+ tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }}
+ draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }}
files: |
.build/macos-dist/StemDeck-macOS-arm64.dmg
.build/StemDeck-runtime-macOS-arm64.tar.zst
@@ -132,3 +297,11 @@ jobs:
.build/macos-dist/StemDeck-macOS-x64.dmg
.build/StemDeck-runtime-macOS-x64.tar.zst
.build/macos-dist/SHA256SUMS-macOS-x64.txt
+
+ - name: remove signing credentials
+ if: always()
+ run: |
+ if [ -n "${APPLE_API_KEY_PATH:-}" ]; then
+ rm -f "$APPLE_API_KEY_PATH"
+ fi
+ rm -f "$RUNNER_TEMP"/AuthKey_*.p8
diff --git a/.github/workflows/release-candidate-guard.yml b/.github/workflows/release-candidate-guard.yml
new file mode 100644
index 00000000..69733996
--- /dev/null
+++ b/.github/workflows/release-candidate-guard.yml
@@ -0,0 +1,141 @@
+name: Release Candidate Guard
+
+# Called by the manual (workflow_dispatch) runs of linux-release.yml,
+# windows-release.yml and macos-release.yml before they build anything (#1027).
+#
+# A manual run builds a release candidate and uploads it to an existing draft
+# pre-release. This guard stops the run unless all of these hold, so a typo can
+# never overwrite the installers of a published release:
+#
+# 1. The tag is a strict release tag: v + MAJOR.MINOR.PATCH, optionally
+# followed by -rc.N, -beta.N or -alpha.N. Nothing else reaches a shell.
+# 2. Exactly one release carries that tag.
+# 3. That release is a draft.
+# 4. That release is marked pre-release.
+# 5. The commit this run builds is the commit the release will be tagged at:
+# the existing git tag's commit if the tag already exists, otherwise the
+# draft's target_commitish, which must then be a full 40-character SHA.
+# A branch target is refused: it is compared with the branch tip when
+# each platform's guard runs, so two platforms could pass on different
+# commits, and publishing would tag whatever the tip is by then.
+#
+# The guard runs once per run, and "Re-run failed jobs" reuses its result. So
+# each build job checks again, on every attempt, right before it uploads, that
+# the release with the guarded id is still a draft with the guarded tag.
+#
+# The job checks out no code and only reads from the API. It still needs
+# `contents: write`, because the releases API lists drafts only to callers with
+# push access; a read-only token sees no drafts at all.
+
+on:
+ workflow_call:
+ inputs:
+ tag:
+ description: "Tag of the draft pre-release to build into, e.g. v0.21.0-rc.1"
+ required: true
+ type: string
+ outputs:
+ tag:
+ description: "The validated tag. Set only when every check passed."
+ value: ${{ jobs.check.outputs.tag }}
+ release_id:
+ description: "The draft's numeric release id. Set only when every check passed."
+ value: ${{ jobs.check.outputs.release_id }}
+
+permissions: {}
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: write
+ outputs:
+ tag: ${{ steps.check.outputs.tag }}
+ release_id: ${{ steps.check.outputs.release_id }}
+ steps:
+ - name: check the tag names a draft pre-release on this commit
+ id: check
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ TAG: ${{ inputs.tag }}
+ RUN_SHA: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+
+ refuse() {
+ echo "::error::Refusing to build: $1"
+ echo "Decision: REFUSED. $1"
+ exit 1
+ }
+
+ # 1. Strict tag shape. The raw value is never echoed: it is free text
+ # typed into the run form, and could carry workflow commands.
+ pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(rc|beta|alpha)\.(0|[1-9][0-9]*))?$'
+ if ! [[ "$TAG" =~ $pattern ]]; then
+ refuse "the tag input is not a release tag (expected v1.2.3 or v1.2.3-rc.1, -beta.1, -alpha.1)."
+ fi
+ echo "Tag: $TAG"
+
+ # 2. Exactly one release with that tag. Drafts are not returned by
+ # the get-by-tag endpoint, so list every release and filter.
+ matches="$(gh api --paginate "repos/$REPO/releases?per_page=100" |
+ jq -c --arg tag "$TAG" '.[] | select(.tag_name == $tag)
+ | {id, draft, prerelease, target_commitish}')"
+ count="$(printf '%s' "$matches" | grep -c . || true)"
+ if [ "$count" -eq 0 ]; then
+ refuse "no release has the tag $TAG. Create the draft pre-release first."
+ fi
+ if [ "$count" -gt 1 ]; then
+ refuse "$count releases have the tag $TAG. Delete the extra drafts so exactly one remains."
+ fi
+ id="$(jq -r .id <<<"$matches")"
+ draft="$(jq -r .draft <<<"$matches")"
+ prerelease="$(jq -r .prerelease <<<"$matches")"
+ target="$(jq -r '.target_commitish // ""' <<<"$matches")"
+ echo "Release $id: draft=$draft prerelease=$prerelease target_commitish=$target"
+
+ # 3 and 4. Draft and pre-release.
+ if [ "$draft" != "true" ]; then
+ refuse "release $TAG is published, not a draft. A manual run only builds into a draft."
+ fi
+ if [ "$prerelease" != "true" ]; then
+ refuse "draft $TAG is not marked pre-release. Tick 'Set as a pre-release' on the draft."
+ fi
+
+ # 5. The commit the release will be tagged at must be the commit this
+ # run builds. An existing tag wins over target_commitish, because
+ # publishing a draft whose tag already exists keeps that tag.
+ tag_ref="$(gh api "repos/$REPO/git/matching-refs/tags/$TAG" |
+ jq -r --arg ref "refs/tags/$TAG" '.[] | select(.ref == $ref) | .ref')"
+ if [ -n "$tag_ref" ]; then
+ source="existing tag $TAG"
+ fix="Run the workflow from the commit the tag points at."
+ expected="$(gh api "repos/$REPO/commits/$tag_ref" --jq .sha)" ||
+ refuse "the existing tag $TAG could not be resolved to a commit."
+ else
+ if ! [[ "$target" =~ ^[0-9a-f]{40}$ ]]; then
+ refuse "draft $TAG targets '$target', not a full commit SHA. A branch target moves, so set the draft's target to the full 40-character SHA to build: gh release edit $TAG --target "
+ fi
+ source="target_commitish $target"
+ fix="Run the workflow from a branch whose tip is that commit, or change the draft's target."
+ expected="$(gh api "repos/$REPO/commits/$target" --jq .sha)" ||
+ refuse "the draft's target $target could not be resolved to a commit."
+ fi
+ echo "Commit this run builds: $RUN_SHA"
+ echo "Commit the release points at ($source): $expected"
+ if [ "$expected" != "$RUN_SHA" ]; then
+ refuse "this run builds $RUN_SHA but $TAG points at $expected ($source). $fix"
+ fi
+
+ if ! [[ "$id" =~ ^[0-9]+$ ]]; then
+ refuse "release $TAG has no numeric id."
+ fi
+
+ echo "Decision: BUILD. Installers will upload to draft pre-release $TAG (release $id) from $RUN_SHA."
+ {
+ echo "tag=$TAG"
+ echo "release_id=$id"
+ } >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/torch-bundle.yml b/.github/workflows/torch-bundle.yml
new file mode 100644
index 00000000..08a0bf0c
--- /dev/null
+++ b/.github/workflows/torch-bundle.yml
@@ -0,0 +1,96 @@
+name: Torch Bundle Check
+
+# The Linux desktop packages swap torch, torchaudio and torchvision for CPU
+# wheels after the project install. 0.20.0 swapped only torch and torchaudio,
+# so the CPU package shipped PyPI's default Linux torchvision, the cu124 build,
+# beside CPU torch, and the karaoke split could not load it (#778). Nothing in
+# CI ran that swap. This job runs it in the order make-portable.sh does, plus
+# the gate that now fails the build, so a regression fails here on the PR.
+#
+# Bounded on purpose. CPU torch goes in before the project install, so the
+# resolver keeps it and never downloads CUDA torch and its ~3 GB of nvidia-*
+# wheels. torchvision still comes from PyPI exactly as the project install
+# resolves it, which is the cu124 build: the pair the 0.20.0 package shipped.
+# The job checks that it reproduced that pair and that the gate rejects it
+# before it runs the real swap, so a pass means the swap fixed something.
+#
+# GitHub-hosted and disposable, no secrets, no fork guard needed (see
+# linux-check.yml).
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "scripts/linux/make-portable.sh"
+ - "scripts/linux/bake-cpu-torch.sh"
+ - "scripts/check_torch_bundle.py"
+ - "packaging/torch-versions.json"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/torch-bundle.yml"
+ push:
+ branches: [main]
+ paths:
+ - "scripts/linux/make-portable.sh"
+ - "scripts/linux/bake-cpu-torch.sh"
+ - "scripts/check_torch_bundle.py"
+ - "packaging/torch-versions.json"
+ - "pyproject.toml"
+ - "uv.lock"
+ - ".github/workflows/torch-bundle.yml"
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ linux-cpu-swap:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ UV_LINK_MODE: copy
+ # Shallow, tagless clone: hatch-vcs cannot derive a version (#169).
+ SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0"
+ VERSIONS: packaging/torch-versions.json
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+ with:
+ version: "0.11.6"
+ enable-cache: "false"
+
+ - name: Create the bundle Python
+ run: |
+ command -v readelf # the gate reads NEEDED entries with it
+ uv venv --python 3.12 "$RUNNER_TEMP/bundle"
+ echo "PY=$RUNNER_TEMP/bundle/bin/python" >> "$GITHUB_ENV"
+ # make-portable.sh installs these first too; the swap runs pip.
+ uv pip install --python "$RUNNER_TEMP/bundle/bin/python" pip setuptools wheel
+
+ - name: Seed CPU torch so the project install never fetches CUDA torch
+ run: |
+ TORCH="$("$PY" -I -c 'import json, sys; print(json.load(open(sys.argv[1]))["cpuTorch"])' "$VERSIONS")"
+ uv pip install --python "$PY" --no-deps \
+ "torch==${TORCH}+cpu" "torchaudio==${TORCH}+cpu" \
+ --index-url https://download.pytorch.org/whl/cpu
+
+ - name: Install StemDeck the way make-portable.sh does
+ run: uv pip install --python "$PY" .
+
+ - name: The unfixed pair is reproduced, and the gate rejects it
+ run: |
+ VISION="$("$PY" -I -c 'import pathlib, re, sysconfig; p = pathlib.Path(sysconfig.get_paths()["purelib"], "torchvision", "version.py"); print(re.search(r"__version__\s*=\s*.([^\x27\x22]+)", p.read_text()).group(1))')"
+ echo "torchvision before the swap: $VISION"
+ case "$VISION" in
+ *+cu*) ;;
+ *) echo "::error::expected PyPI's CUDA torchvision before the swap, got $VISION; this job no longer reproduces #778"; exit 1 ;;
+ esac
+ if "$PY" scripts/check_torch_bundle.py --versions "$VERSIONS" --expect-cpu; then
+ echo "::error::the gate passed a CUDA torchvision beside CPU torch"
+ exit 1
+ fi
+
+ - name: Bake CPU torch and run the gate (the step make-portable.sh runs)
+ run: bash scripts/linux/bake-cpu-torch.sh "$PY"
diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml
index e072eb80..5519b329 100644
--- a/.github/workflows/windows-check.yml
+++ b/.github/workflows/windows-check.yml
@@ -1,7 +1,6 @@
name: Windows Rust Check
-# Manual only -- not on every PR/push, to keep the self-hosted runner's load
-# and cost unchanged from today. Companion to macos-check.yml: ci.yml (which
+# Manual, plus PRs that touch the Rust (see below). Companion to macos-check.yml: ci.yml (which
# does run on every PR) is 100% ubuntu-latest, so nothing behind
# #[cfg(windows)] -- or, as it turned out, code that lost a #[cfg(unix)] gate
# and started compiling on Windows by accident -- has ever had a real
@@ -11,8 +10,7 @@ on:
workflow_dispatch:
# Also on PRs that actually touch the Rust, so a desktop-shell change cannot
# merge without a real compiler pass on this platform. Scoped by path so the
- # self-hosted runner's load is unchanged for the majority of PRs, which do not
- # go near src-tauri. (#421 added ~600 lines of mostly cfg-gated Rust and every
+ # majority of PRs, which do not go near src-tauri, skip it. (#421 added ~600 lines of mostly cfg-gated Rust and every
# CI check passed without compiling any of it.)
pull_request:
paths:
@@ -27,32 +25,41 @@ concurrency:
jobs:
check:
- # Never run a fork's code on the self-hosted runner -- see the same guard in
- # macos-check.yml. cargo build/clippy/test execute whatever the PR supplies
- # (build.rs, proc-macro crates, a swapped Cargo.toml dependency, the test
- # bodies), nothing here cleans the workspace, and this runner also builds
- # the Windows release. Fork PRs get a maintainer-triggered
- # workflow_dispatch run instead.
+ # Fork PRs get a maintainer-triggered workflow_dispatch run instead. This
+ # runner is GitHub-hosted and disposable, so the guard is no longer needed
+ # for safety; it is kept so fork PRs do not start compiles on their own.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
- # Runner must have rustup and the MSVC toolchain (same requirements as
- # windows-release.yml, which this intentionally does not replace -- this
- # only builds/checks, never packages or uploads anything).
- runs-on: [self-hosted, windows, x64]
+ # windows-latest ships rustup and the MSVC toolchain. This intentionally
+ # does not replace windows-release.yml -- this only builds/checks, never
+ # packages or uploads anything.
+ runs-on: windows-latest
timeout-minutes: 30
+ env:
+ CARGO_INCREMENTAL: 0
defaults:
run:
- # bash on this self-hosted runner mangles the auto-generated temp
- # script's Windows-style path (backslashes stripped -- "No such file
- # or directory" on a path that plainly exists), the way
- # windows-release.yml's own steps already discovered. powershell is
- # the proven shell for this runner.
+ # Kept as powershell: it is the shell this job has always run under
+ # (bash mangled the temp script's Windows path on the old runner).
shell: powershell
working-directory: desktop/src-tauri
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ # Compiled dependencies are most of this job's time. Keyed on Cargo.lock, so a
+ # dependency change starts a fresh cache and the closest older one seeds it.
+ - name: cache cargo
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ desktop/src-tauri/target
+ key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('desktop/src-tauri/Cargo.lock') }}
+ restore-keys: |
+ cargo-${{ runner.os }}-${{ runner.arch }}-
+
- name: cargo fmt --check
run: cargo fmt --check
diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml
index 75c0dc39..2384d900 100644
--- a/.github/workflows/windows-release.yml
+++ b/.github/workflows/windows-release.yml
@@ -3,15 +3,43 @@ name: Windows Release
on:
release:
types: [published]
+ # Release candidate build (#1027): builds the commit this run is started on
+ # and uploads to an existing draft pre-release with this tag. The guard job
+ # refuses any tag that is not exactly one draft pre-release targeting that
+ # commit, so a published release can never be overwritten from here.
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Tag of an existing draft pre-release, e.g. v0.21.0-rc.1"
+ required: true
+ type: string
permissions: {}
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ # A manual run shares the group of the release it builds into. Only a
+ # release event cancels what is in progress: publishing a draft cancels a
+ # candidate build still running for it, while a manual run never cancels a
+ # release build. The group comes from the raw input and applies before the
+ # guard, so a manual run typed with a published tag waits behind that
+ # release's build, and its guard then refuses. On a release event inputs.tag
+ # is empty and the group is the same as before.
+ group: ${{ github.workflow }}-${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'release' }}
jobs:
+ guard:
+ if: github.event_name == 'workflow_dispatch'
+ uses: ./.github/workflows/release-candidate-guard.yml
+ permissions:
+ contents: write
+ with:
+ tag: ${{ inputs.tag }}
+
build-and-upload:
+ needs: guard
+ # A release event skips the guard; a manual run builds only if it passed.
+ if: ${{ !cancelled() && (github.event_name == 'release' || needs.guard.result == 'success') }}
# Runner must be windows/x64 with PowerShell, Docker, and rustup.
runs-on: [self-hosted, windows, x64]
timeout-minutes: 90
@@ -20,8 +48,10 @@ jobs:
# 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)
+ # A manual run takes the tag the guard validated instead. If the guard set
+ # nothing it is empty, and the version step stops the build.
env:
- REF_NAME: ${{ github.ref_name }}
+ REF_NAME: ${{ github.event_name == 'release' && github.ref_name || needs.guard.outputs.tag }}
defaults:
run:
shell: powershell
@@ -43,8 +73,6 @@ jobs:
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"
@@ -111,6 +139,83 @@ jobs:
if (-not (Test-Path $f)) { Write-Error "missing release asset: $f"; exit 1 }
}
+ # The guard ran once, at the start of this run, and "Re-run failed jobs"
+ # reuses its result. Check again on every attempt, right before the
+ # upload, that the guarded release is still a draft pre-release with the
+ # guarded tag, pointing at the commit this run built. Without this, a
+ # re-run after publishing would overwrite the published assets, and a
+ # re-run of an older run would overwrite a retargeted draft's newer ones.
+ - name: check the release is still the guarded draft
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ API_URL: ${{ github.api_url }}
+ REPO: ${{ github.repository }}
+ RELEASE_ID: ${{ needs.guard.outputs.release_id }}
+ TAG: ${{ needs.guard.outputs.tag }}
+ RUN_SHA: ${{ github.sha }}
+ run: |
+ if ($env:RELEASE_ID -notmatch '^[0-9]+$' -or -not $env:TAG -or $env:RUN_SHA -cnotmatch '^[0-9a-f]{40}$') {
+ throw "Refusing to upload: the guard set no release id or tag, or the run has no commit SHA."
+ }
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
+ $headers = @{
+ "Authorization" = "Bearer $env:GH_TOKEN"
+ "Accept" = "application/vnd.github+json"
+ "X-GitHub-Api-Version" = "2022-11-28"
+ }
+ function Get-Api([string]$Path) {
+ Invoke-RestMethod -Method Get -Uri "$env:API_URL/repos/$env:REPO/$Path" -Headers $headers
+ }
+ # A JSON object arrives as a PSCustomObject; an array, a string or a
+ # number does not, and a field of the wrong type is refused as well.
+ $objectType = [System.Management.Automation.PSCustomObject]
+
+ $release = Get-Api "releases/$env:RELEASE_ID"
+ if ($release -isnot $objectType -or
+ $release.tag_name -isnot [string] -or
+ $release.draft -isnot [bool] -or
+ $release.prerelease -isnot [bool] -or
+ $release.target_commitish -isnot [string]) {
+ throw "Refusing to upload: release $($env:RELEASE_ID) did not come back as a release object."
+ }
+ Write-Host "Release $($env:RELEASE_ID): tag=$($release.tag_name) draft=$($release.draft) prerelease=$($release.prerelease) target_commitish=$($release.target_commitish)"
+ if ($release.tag_name -cne $env:TAG) {
+ throw "Refusing to upload: release $($env:RELEASE_ID) now has the tag $($release.tag_name), not $($env:TAG)."
+ }
+ if ($release.draft -ne $true) {
+ throw "Refusing to upload: release $($env:TAG) is no longer a draft; its own release build owns its assets."
+ }
+ if ($release.prerelease -ne $true) {
+ throw "Refusing to upload: release $($env:TAG) is no longer marked pre-release."
+ }
+
+ # The guard's commit check, repeated: an existing tag's commit,
+ # otherwise the draft's target, must be the commit this run built.
+ # Windows PowerShell 5.1 returns a JSON array as one Object[] value.
+ $refs = Get-Api "git/matching-refs/tags/$env:TAG"
+ if ($null -ne $refs -and $refs -isnot [array]) {
+ throw "Refusing to upload: the tag lookup for $($env:TAG) did not come back as a list."
+ }
+ $exact = @(@($refs) | Where-Object { $_ -is $objectType -and $_.ref -ceq "refs/tags/$($env:TAG)" })
+ if ($exact.Count -gt 0) {
+ $commit = Get-Api "commits/refs/tags/$env:TAG"
+ if ($commit -isnot $objectType -or $commit.sha -isnot [string]) {
+ throw "Refusing to upload: the tag $($env:TAG) did not resolve to a commit."
+ }
+ $expected = $commit.sha
+ $source = "existing tag $($env:TAG)"
+ } else {
+ $expected = $release.target_commitish
+ $source = "target_commitish"
+ }
+ Write-Host "Commit this run built: $($env:RUN_SHA)"
+ Write-Host "Commit the release points at ($source): $expected"
+ if ($expected -cne $env:RUN_SHA) {
+ throw "Refusing to upload: this run built $($env:RUN_SHA) but $($env:TAG) now points at $expected ($source). Start a new run instead of re-running this one."
+ }
+ Write-Host "Decision: UPLOAD to draft $($env:TAG) (release $($env:RELEASE_ID))."
+
- name: upload artifacts
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
@@ -119,7 +224,12 @@ jobs:
# pre-release to the latest release -- which also fires `released`,
# pushing :latest to GHCR, and makes the in-app updater offer a build
# that was never verified.
- prerelease: ${{ github.event.release.prerelease }}
+ prerelease: ${{ github.event.release.prerelease || github.event_name == 'workflow_dispatch' }}
+ # A manual run uploads to the draft named by the guarded tag. Without
+ # draft: true the action publishes the draft after uploading. On a
+ # release event both are empty and the action keeps its defaults.
+ tag_name: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.tag || '' }}
+ draft: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }}
fail_on_unmatched_files: true
files: |
dist/StemDeck-Windows-x64.NVIDIA.zip
diff --git a/.trivyignore b/.trivyignore
index 81f03eac..31c3f02d 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -8,6 +8,18 @@
# the torchaudio/torchcodec story stabilises and the pin can be lifted.
CVE-2025-32434
+# CVE-2026-24747: torch below 2.10.0, memory corruption in the weights_only
+# unpickler. Exploiting it needs a victim process to torch.load() a crafted
+# checkpoint file. StemDeck's app/ has no torch.load() call (checked with
+# grep; the only matches are the names of the torch_load_error helpers in
+# app/core/config.py). All model weights are fetched by Demucs from its own
+# trusted cache, and no checkpoint is ever loaded from user input.
+# torch cannot move yet: pyproject.toml pins torch and torchaudio to 2.6.x
+# (2.2.x on Intel macOS) because torchaudio 2.7 and later need torchcodec,
+# which has ABI problems with newer torch. Tracked in #1016.
+# Drop this ignore once the pin moves to torch 2.10 or later.
+CVE-2026-24747
+
# 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
@@ -16,39 +28,3 @@ CVE-2025-32434
# executes. Trivy's check does not account for the gosu privilege-drop
# pattern.
DS-0002
-
-# CVE-2026-63374: anyio's TLSStream encodes host names with IDNA 2003, which
-# can be made to disagree with IDNA 2008 and spoof a TLS certificate. Fixed in
-# anyio 4.14.2; the lock pins 4.13.0.
-#
-# StemDeck never opens a TLSStream. anyio is here transitively: starlette uses
-# it for task groups and thread offloading, not for sockets, and httpx (which
-# does use it for TLS) is a dev-only dependency, present for FastAPI's
-# TestClient. The server itself speaks plain HTTP on localhost, and the two
-# things that do fetch over TLS, yt-dlp and audio-separator, bring their own
-# HTTP stacks.
-#
-# Not fixed in place because the fix is not a one-line bump: regenerating the
-# lock for it also rewrites platform markers across a dozen packages, including
-# the Intel macOS paths that pyproject gates by hand, and any uv.lock change
-# sends every existing desktop install to a full download instead of an in-app
-# update. Take the bump the next time the lock moves for another reason, and
-# drop this entry then.
-CVE-2026-63374
-
-# CVE-2026-97687 (traffic interception through an HTTPS proxy's TLS
-# configuration) and CVE-2026-97689 (denial of service through unbounded
-# memory): urllib3, fixed in 2.8.0; the lock pins 2.7.0.
-#
-# StemDeck's own requests all go through the standard library's urllib.
-# urllib3 is here only through requests, which audio-separator, librosa's
-# pooch and Whisper's tiktoken use to download models and data from fixed
-# hosts (GitHub, Hugging Face, OpenAI's CDN). The first needs an HTTPS proxy
-# configured, the second a hostile server on one of those downloads.
-#
-# Not bumped now for the same reason as CVE-2026-63374: any uv.lock change
-# sends every existing desktop install to a full download instead of an
-# in-app update, and 0.19.0 already did that two days before 0.19.1. Take the
-# bump the next time the lock moves for another reason, and drop these then.
-CVE-2026-97687
-CVE-2026-97689
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 483f1776..9e1b7801 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,8 +85,9 @@ New API endpoints and pipeline stages should come with tests under `tests/`. The
## License
-StemDeck is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree that your
-contributions are licensed under the same terms.
+StemDeck is licensed under the [GNU Affero General Public License v3.0 only](LICENSE)
+(AGPL-3.0-only). Contributions are accepted under the same license: by contributing, you agree
+that your contributions are licensed under AGPL-3.0-only.
## Questions
diff --git a/LICENSE b/LICENSE
index 261eeb9e..0ad25db4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,201 +1,661 @@
- 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.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/README.md b/README.md
index 55980809..b2d97c79 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,9 @@
-StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano and other, and opens them in a studio where you can mute, solo, slow down, loop, change the key and play along with a click. Drop in an audio file or paste a YouTube or SoundCloud link. Separation runs on your own machine, and StemDeck does not upload your audio.
+StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano and other, and opens them in a studio where you can mute, solo, slow down, loop, change the key and play along with a click. Sing along in Karaoke, or film yourself playing along in Performance. Drop in an audio file or paste a YouTube or SoundCloud link. Separation runs on your own machine, and StemDeck does not upload your audio.
-> **A separation tool, not a downloader.** StemDeck is for processing audio you have the right to use. What you import, and the stems made from it, are saved in your library folder on the machine that runs StemDeck; StemDeck does not upload or share them. See the [Disclaimer](#disclaimer).
+> **A separation tool, not a downloader.** StemDeck is for processing audio you have the right to use. What you import, the stems made from it, and the takes you record, are saved in your library folder on the machine that runs StemDeck; StemDeck does not upload or share them. See the [Disclaimer](#disclaimer).
@@ -60,25 +60,28 @@ StemDeck splits a song into up to six stems, vocals, drums, bass, guitar, piano
Each link downloads the latest release. [Release notes](https://github.com/stemdeckapp/stemdeck/releases/latest) · [Every version](https://github.com/stemdeckapp/stemdeck/releases)
-| Platform | NVIDIA or Apple Silicon | Everything else |
+| Platform | NVIDIA, AMD (Linux) or Apple Silicon | Everything else |
|---|---|---|
| **Windows** | [NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Windows-x64.NVIDIA.zip) | [No NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Windows-x64.zip) |
| **macOS** | [Apple Silicon (M1 and later)](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-macOS-arm64.dmg) | [Intel Mac](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-macOS-x64.dmg) |
-| **Linux** | [NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.NVIDIA.tar.gz) | [No NVIDIA GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.tar.gz) |
+| **Linux** | [NVIDIA or AMD GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.NVIDIA.tar.gz) | [No NVIDIA or AMD GPU](https://github.com/stemdeckapp/stemdeck/releases/latest/download/StemDeck-Linux-x64.tar.gz) |
- **Docker / Unraid:** [`ghcr.io/stemdeckapp/stemdeck:latest`](https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck). It runs on the CPU as it is; add `--runtime=nvidia` to use an NVIDIA GPU. On Unraid, search "StemDeck" in Apps.
+ **Docker / Unraid:** [`ghcr.io/stemdeckapp/stemdeck:latest`](https://github.com/stemdeckapp/stemdeck/pkgs/container/stemdeck). It runs on the CPU as it is; add `--runtime=nvidia` to use an NVIDIA GPU. For an AMD GPU (community-tested), use the `:rocm` tag with `--device /dev/kfd --device /dev/dri`. On Unraid, search "StemDeck" in Apps.
-What's new in 0.20.0
+What's new in 0.21.0
-- **Guide.** A new button in the left rail turns the studio into its own manual: point at anything to see what it does, and every number in the song facts says what it measures.
-- **The Trash.** A count on its icon, hold a song for a second to bring it back, and pick several at once with Ctrl or Shift.
-- **Deleting a song while it extracts stops it,** and keeps its files until you empty the Trash.
-- **Imports stay in the background:** the song you have open stays open.
-- **We Recommend** welcomes Adrianna Claro, and cards can now link to Spotify and Apple Music.
-- **Portable Windows installs** in a folder they cannot write to now say so clearly.
+- **Four modes:** Practice, Advanced, Karaoke and Performance, all in one Mode menu.
+- **Karaoke.** The lyrics fill the window and light up as the song plays, with the voice off and a queue of songs.
+- **Performance.** Film yourself playing along, review the take, trim it and export an MP4.
+- **Each mode keeps its own stems and its own saved mix** of a song.
+- **Advanced** gains an EQ on every lane, a Master with a loudness target, recording tracks and video tracks.
+- **A redesigned Settings,** with a section list and a search box.
+- **A GPU video encoder** for Performance exports, with an automatic fall-back to the CPU.
+- **Safer by default:** a guard against requests from other websites, auto-delete that skips favorites, songs with takes or versions, and songs still being worked on, and a library that survives one bad record.
+- **Imports:** a Detect lyrics switch, and Try again on an import that failed.
All of it, with credits to the people who reported each problem, is in the [release notes](https://github.com/stemdeckapp/stemdeck/releases/latest).
@@ -89,7 +92,7 @@ All of it, with credits to the people who reported each problem, is in the [rele
-**Windows and Linux.** Unzip, then run `StemDeck.exe` (Windows) or `StemDeck` (Linux). No installer and nothing else to install. Put the folder somewhere you can write to, such as `C:\StemDeck`: a portable StemDeck keeps its runtime, models and settings in a `data` folder beside it, so it cannot run from Program Files. The first launch downloads FFmpeg and the separation model, and on an NVIDIA card sets up GPU support; later launches start in seconds.
+**Windows and Linux.** Unzip, then run `StemDeck.exe` (Windows) or `StemDeck` (Linux). No installer and nothing else to install. Put the folder somewhere you can write to, such as `C:\StemDeck`: a portable StemDeck keeps its runtime, models and settings, and on Windows your songs by default, in a `data` folder beside it, so it cannot run from Program Files. The first launch downloads FFmpeg and the separation model, and on an NVIDIA card (or, on Linux, an AMD card ROCm supports, such as the RX 7000 and RX 9000 series) sets up GPU support; later launches start in seconds.
**macOS.** Open the DMG and drag StemDeck to Applications. StemDeck is not code-signed yet, so clear the quarantine flag once, or macOS will say the app is damaged:
@@ -101,7 +104,7 @@ The first launch downloads the runtime and the model. Apple Silicon separates on
**Docker and Unraid.** See [Build from source](docs/build-from-source.md#docker) for the `docker run` line, GPU passthrough and the Unraid volume mapping.
-**Updates.** The desktop app tells you when a new version is out and installs it for you.
+**Updates.** The desktop app tells you when a new version is out. On Windows and Linux it usually installs the update for you. A version that changes the Python packages StemDeck runs on is a full download instead, and 0.21.0 is one: about 300 MB on Windows and 420 MB on Linux, once. On Windows, unzip it over your current StemDeck folder. The zip has no files inside `data`, so your songs, models and settings there stay as they are. A portable StemDeck no longer opens a library in `Documents\StemDeck\jobs` on its own: if yours is there, choose that folder once in Settings, StemData location. Unzipped into a new folder, StemDeck starts with default settings, without the songs from the old `data` folder, and downloads FFmpeg and the model again. On macOS, download the new DMG.
@@ -109,6 +112,17 @@ The first launch downloads the runtime and the model. Apple Silicon separates on
## What you can do
+
+
+### Modes
+
+Pick one from **Mode** in the left rail. Each mode keeps its own choice of stems and its own saved mix of a song, so a change in one does not follow you into another.
+
+- **Practice:** stems, mixer, speed, key, click and loop, without recording, EQ or the master.
+- **Advanced:** everything, with recording tracks, take folders, an EQ on every lane, the master and video.
+- **Karaoke:** the lyrics fill the window and light up word by word as the song plays. Switch the voice and the backing vocals on or off, and drag songs from the library into a queue.
+- **Performance:** film yourself playing along to the song, then export the video.
+
### Split any song
- **Up to six stems** with Meta's open Demucs model: vocals, drums, bass, guitar, piano and other.
@@ -116,6 +130,7 @@ The first launch downloads the runtime and the model. Apple Silicon separates on
- **Files or links:** MP3, WAV, FLAC, OGG, Opus, M4A and MP4, or a YouTube or SoundCloud link.
- **Search without leaving the app:** YouTube songs and playlists, and SoundCloud, with a preview.
- **Pick only what you need** before you extract.
+- **Choose the lanes in each mode** with **Stems**: the stems you pick get their own lane, the rest play together in Original. Nothing is separated again.
- **Whole YouTube playlists** go into an import queue and run one after another.
### Practice with it
@@ -127,11 +142,21 @@ The first launch downloads the runtime and the model. Apple Silicon separates on
- **Play with a click** that follows the song, with a count-in, any beats per bar and groupings such as 3+2+2.
- **Fix the beat grid** when the click drifts: move, add or remove beats and mark the bar lines.
+### Film yourself playing
+
+In Performance mode:
+
+- **Your camera and your input:** pick a camera, or none, and your microphone or audio interface input.
+- **Say what you play** with **I play**, and that part is taken out of the backing track you hear and out of the video.
+- **Review the take** in step with the song, and trim its start and end on the **Take Timeline**.
+- **Export to MP4** at 720p or 1080p, at 24, 30 or 60 fps. StemDeck uses your graphics card when its encoder passes a short test on your computer, and the CPU otherwise. **Settings > Export** has a Video encoder choice and a Test encoders button.
+- **The video stays on your computer.** Uploading and live streaming are not part of this release.
+
### Know the song
- **Key, tempo and loudness,** found for you.
- **Sections** such as verse, chorus and solo, detected for you (experimental) or marked by hand.
-- **Synced lyrics** from LRCLIB, with timing you can fix by tapping along. With an NVIDIA GPU, StemDeck can also write them down from the vocals when none are found.
+- **Synced lyrics** from LRCLIB, with timing you can fix by tapping along. With an NVIDIA GPU, or an AMD one on Linux, StemDeck can also write them down from the vocals when none are found.
- **Song and artist details** from MusicBrainz, Wikipedia and, optionally, Discogs.
### Keep and share
@@ -142,7 +167,9 @@ The first launch downloads the runtime and the model. Apple Silicon separates on
### It explains itself
-Click **Guide** in the left rail and point at anything. Every button, lane control and number says what it does, in plain words. While Guide is on, clicks explain instead of acting, so nothing happens by accident.
+Click **Guide** in the left rail and point at anything. Every button, lane control and number says what it does, in plain words, and so do the Mode menu and the options in Settings. While Guide is on, clicks explain instead of acting, so nothing happens by accident.
+
+**Settings** has a list of sections on the left and a search box, and most changes save as soon as you make them.
### On your phone too
@@ -247,7 +274,7 @@ StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](ht
## Disclaimer
-StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. Audio you import, and the stems made from it, are saved only in the library folder on the machine that runs StemDeck, and StemDeck does not upload, share or redistribute them. To find song details and lyrics it sends the song's name to the services named in its Settings and, only if you add an AcoustID key, an audio fingerprint; it does not send the audio itself.
+StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. Audio you import, the stems made from it, and the takes you record, are saved only in the library folder on the machine that runs StemDeck, and StemDeck does not upload, share or redistribute them. To find song details and lyrics it sends the song's name to the services named in its Settings and, only if you add an AcoustID key, an audio fingerprint; it does not send the audio itself.
YouTube and SoundCloud link 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.
@@ -258,9 +285,11 @@ The author(s) of StemDeck provide this software "as is", without warranty of any
## License
-StemDeck is [Apache-2.0](LICENSE).
+StemDeck is free software under the [GNU Affero General Public License v3.0 only](LICENSE) (AGPL-3.0-only). You may use, study, change and share it. If you change it and let other people use your version over a network, the AGPL asks you to offer them its source code too; the About dialog links to the source for that reason.
-Every download ships a `THIRD_PARTY_NOTICES.txt` and a `licenses/` folder. `licenses/INDEX.txt` lists each packaged Python dependency with its version and license, and each one's full license text sits beside it. That inventory is generated from the packaged interpreter at build time, so it describes what actually shipped rather than what was expected to.
+Releases up to and including 0.20.0 were published under Apache-2.0, and copies received under those terms keep them.
+
+Every download ships StemDeck's own `LICENSE.txt`, a `THIRD_PARTY_NOTICES.txt` and a `licenses/` folder. `licenses/INDEX.txt` lists each packaged Python dependency with its version and license, and each one's full license text sits beside it. That inventory is generated from the packaged interpreter at build time, so it describes what actually shipped rather than what was expected to.
FFmpeg is a GPL build. StemDeck downloads it and runs it as a separate executable, so it does not change StemDeck's own license, and `THIRD_PARTY_NOTICES.txt` carries the written offer of source for the exact build your platform receives.
@@ -281,3 +310,5 @@ Questions, ideas and show-and-tell are all welcome.
## Contributing
Issues, feature suggestions and pull requests are welcome. See the [open issues](https://github.com/stemdeckapp/stemdeck/issues) for what is planned, and [Build from source](docs/build-from-source.md) to run it yourself.
+
+If you would like to support the time I put into StemDeck, a Donate button shows in the app's left rail as soon as donating is set up. Donating is optional: StemDeck stays free, with every feature, for everyone.
diff --git a/SECURITY.md b/SECURITY.md
index 9b76a6f9..5dd55051 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -37,6 +37,11 @@ What to expect:
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.
+The desktop backend listens on `127.0.0.1` only while network access is off,
+so other devices cannot connect, and on every interface once it is on. A
+server (Docker, Unraid, `run.sh`) listens on every interface and refuses other
+devices itself while network access is off; see
+[Who can reach StemDeck](docs/configuration.md#who-can-reach-stemdeck).
We are most interested in reports about:
diff --git a/app/api/backing.py b/app/api/backing.py
new file mode 100644
index 00000000..4227c9f1
--- /dev/null
+++ b/app/api/backing.py
@@ -0,0 +1,113 @@
+"""A song's backing track without one stem (Streamer Mode's "I play"), and
+the rest of a song for any set of stems (a studio mode's Original).
+
+ GET|HEAD /api/jobs/{id}/backing/{stem}.wav the backing, built on first ask
+ GET|HEAD /api/jobs/{id}/rest/{set}.wav the sum of `set`, built on first ask
+
+`set` is base stems joined by "-", sorted, each once (app/pipeline/backing.py
+parse_rest_set): one set has one spelling, so one cached file.
+
+`stem` is one of the six Demucs stems. The file is mixed from the stems on
+disk the first time it is asked for and kept in the data cache
+(app/pipeline/backing.py); later requests are served from it, Range requests
+included (the audio engine reads WAVs in windows). Errors say what is wrong,
+never where on disk.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+
+from app.core.config import JOB_ID_RE, JOBS_DIR, TIMEOUT_BACKING
+from app.core.registry import get as registry_get
+from app.pipeline.backing import (
+ REMOVABLE,
+ BackingError,
+ ensure_backing,
+ ensure_rest,
+ in_cache,
+ parse_rest_set,
+)
+
+logger = logging.getLogger("stemdeck.api")
+
+router = APIRouter(tags=["jobs"])
+
+
+@router.api_route("/{job_id}/backing/{stem}.wav", methods=["GET", "HEAD"], response_model=None)
+async def get_backing(job_id: str, stem: str) -> FileResponse:
+ # Validation order (security skill): the job id, then the stem against
+ # the whitelist, then the resolved job folder inside the jobs folder, then
+ # I/O; the built file is served only from inside the backing cache.
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ if stem not in REMOVABLE:
+ raise HTTPException(status_code=422, 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")
+ root = JOBS_DIR.resolve()
+ job_dir = (JOBS_DIR / job_id).resolve()
+ if not job_dir.is_relative_to(root) or not job_dir.is_dir():
+ raise HTTPException(status_code=404, detail="job not found")
+ try:
+ path, built = await asyncio.wait_for(
+ asyncio.to_thread(ensure_backing, job_id, job_dir, stem), timeout=TIMEOUT_BACKING
+ )
+ except BackingError:
+ logger.warning("[%s] backing without %s not made", job_id, stem, exc_info=True)
+ raise HTTPException(status_code=404, detail="no stems to make a backing from") from None
+ except TimeoutError:
+ logger.warning("[%s] backing without %s timed out", job_id, stem)
+ raise HTTPException(status_code=500, detail="the backing took too long") from None
+ except OSError:
+ logger.warning("[%s] backing without %s failed", job_id, stem, exc_info=True)
+ raise HTTPException(status_code=500, detail="the backing could not be made") from None
+ if built:
+ logger.info("[%s] backing without %s built", job_id, stem)
+ # Not a file: the song was deleted since (forget_job).
+ if not path.is_file() or not in_cache(path):
+ raise HTTPException(status_code=404, detail="job not found")
+ return FileResponse(path, media_type="audio/wav")
+
+
+@router.api_route("/{job_id}/rest/{stem_set}.wav", methods=["GET", "HEAD"], response_model=None)
+async def get_rest(job_id: str, stem_set: str) -> FileResponse:
+ """The sum of the stems in `stem_set`: a studio mode's Original when its
+ lanes differ from the song's own selection. Same validation order and
+ errors as the backing."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ try:
+ names = parse_rest_set(stem_set)
+ except ValueError:
+ raise HTTPException(status_code=422, detail="unknown stem set") from None
+ job = registry_get(job_id)
+ if job is None or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not ready")
+ root = JOBS_DIR.resolve()
+ job_dir = (JOBS_DIR / job_id).resolve()
+ if not job_dir.is_relative_to(root) or not job_dir.is_dir():
+ raise HTTPException(status_code=404, detail="job not found")
+ try:
+ path, built = await asyncio.wait_for(
+ asyncio.to_thread(ensure_rest, job_id, job_dir, names), timeout=TIMEOUT_BACKING
+ )
+ except BackingError:
+ logger.warning("[%s] rest %s not made", job_id, stem_set, exc_info=True)
+ raise HTTPException(status_code=404, detail="the stems for this set are missing") from None
+ except TimeoutError:
+ logger.warning("[%s] rest %s timed out", job_id, stem_set)
+ raise HTTPException(status_code=500, detail="the mix took too long") from None
+ except OSError:
+ logger.warning("[%s] rest %s failed", job_id, stem_set, exc_info=True)
+ raise HTTPException(status_code=500, detail="the mix could not be made") from None
+ if built:
+ logger.info("[%s] rest %s built", job_id, stem_set)
+ if not path.is_file() or not in_cache(path):
+ raise HTTPException(status_code=404, detail="job not found")
+ return FileResponse(path, media_type="audio/wav")
diff --git a/app/api/discogs.py b/app/api/discogs.py
index 9593602b..d9e29404 100644
--- a/app/api/discogs.py
+++ b/app/api/discogs.py
@@ -46,7 +46,7 @@ def cancelled() -> bool:
return await asyncio.wait_for(
asyncio.to_thread(func, *args, cancelled=cancelled), DISCOGS_SEARCH_BUDGET_SEC + 1
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("Discogs artist request timed out")
return None
diff --git a/app/api/jobs.py b/app/api/jobs.py
index af132f5b..107b231a 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -7,10 +7,12 @@
import os
import re
import shutil
+import stat
import subprocess
import threading
import time
import uuid
+from collections import deque
from pathlib import Path
from typing import Literal
@@ -18,10 +20,13 @@
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, StrictBool, field_validator
+from app.api.takes import cancel_job_renders, forget_job_locks
from app.core.config import (
DISCOGS_LOOKUP_BUDGET_SEC,
JOB_ID_RE,
JOBS_DIR,
+ LIBRARY_RACY_WINDOW_NS,
+ LOUDNESS_UPGRADE_QUEUE_MAX,
LYRICS_LOOKUP_BUDGET_SEC,
LYRICS_OFFSET_MAX_SEC,
LYRICS_TIMED_MAX_CHARS,
@@ -33,14 +38,24 @@
TIMEOUT_IDENTIFY_BACKFILL,
TIMEOUT_LYRICS_LOOKUP,
TIMEOUT_WORK_BACKFILL,
+ UPLOAD_EXTS,
ffprobe_executable,
)
-from app.core.models import FINISHED_STATUSES, Job, JobCancelled, _set, settle_cancelled
+from app.core.models import (
+ FINISHED_STATUSES,
+ LOUDNESS_VERSION,
+ Job,
+ JobBusy,
+ JobCancelled,
+ _set,
+ settle_cancelled,
+)
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 is_upload
+from app.core.registry import is_upload, keep_added_time, metadata_lock, saved_fields
from app.core.registry import mark_deleted as registry_mark_deleted
+from app.core.registry import operation as registry_operation
from app.core.registry import pending_count as registry_pending_count
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
@@ -50,14 +65,31 @@
from app.core.registry import set_trashed as registry_set_trashed
from app.core.settings import (
get_acoustid_api_key,
+ get_auto_lyrics,
get_auto_sections,
get_discogs_token,
get_max_duration_sec,
)
+from app.core.stem_files import stem_path, stem_url
from app.core.stems_location import is_relocating
-from app.pipeline import discogs, jobqueue
+from app.core.versions import (
+ MAX_VERSIONS,
+ VERSION_ID_RE,
+ VERSION_NAME_MAX_CHARS,
+ active_of,
+ clean_name,
+ clean_stems,
+ make_version,
+ versions_of,
+)
+from app.core.vocal_split_outcome import CANCELLED as SPLIT_CANCELLED
+from app.core.vocal_split_outcome import WORKER_FAILED as SPLIT_WORKER_FAILED
+from app.core.vocal_split_outcome import message_for as split_message_for
+from app.pipeline import discogs, jobqueue, vocal_split_status
+from app.pipeline.analyze import dynamic_range_db, measure_loudness
from app.pipeline.artist_lookup import tagged_artist_name
from app.pipeline.audio_tags import probe_tags
+from app.pipeline.backing import forget_job as forget_backing
from app.pipeline.collect import merge_stem_peaks, presence_for_split, remove_job_dir
from app.pipeline.download import InvalidYouTubeURL, fetch_audio_tags, validate_youtube_url
from app.pipeline.errors import classify_failure
@@ -93,13 +125,14 @@
)
from app.pipeline.lyrics_retime import saved_view as saved_retime_view
from app.pipeline.runner import _pipeline_lock
-from app.pipeline.vocal_split import split_vocals
+from app.pipeline.stem_fill import FillRefused, cancel_fill, fill_stems, missing_stems
+from app.pipeline.stem_storage import store_split_stems
+from app.pipeline.vocal_split import VocalSplitError, split_vocals
from app.pipeline.work_lookup import find_work, might_have_work
router = APIRouter(tags=["jobs"])
logger = logging.getLogger("stemdeck.api")
-_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus"))
_MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB
_WS_RE = re.compile(r"\s+")
@@ -175,36 +208,162 @@ def _rmtree_job(job_id: str) -> bool:
return remove_job_dir(JOBS_DIR / job_id)
-def _job_files_missing(job: Job) -> bool:
+# GET /api/jobs reports every finished song's files as present or missing, on
+# every call (#976). Asking the disk afresh each time cost two path resolves, a
+# stat and a directory listing per song, which is one network round trip after
+# another on an SMB or NAS library. The answer is now kept per song, beside the
+# identity and modification time of the stems folder it was read from, and used
+# only while one lstat of that folder still returns exactly those values. The
+# song folder itself is checked from one listing of the jobs folder per request
+# (_LibraryView), not one call per song.
+#
+# What invalidates it: deleting, renaming or replacing the song folder or its
+# stems folder (the folder is gone from the listing, the lstat fails, or the
+# identity changes), adding or removing a file directly in the stems folder
+# (its modification time changes), either folder turned into a link or junction
+# (no longer a plain folder, so the full check runs and refuses a target
+# outside the library, as the serving routes do). Nothing expires on a timer,
+# because nothing needs to: every change that alters the answer alters the key.
+# The jobs folder is not part of the key: JOBS_DIR is fixed for the life of the
+# process (a library move restarts it), and any other path to the same folder
+# goes through a link, which the plain-folder checks already refuse to trust.
+#
+# What it cannot see: a stems folder put back with its exact identity and
+# modification time, which only a timestamp-preserving copy onto the same file
+# ID does. A change made within LIBRARY_RACY_WINDOW_NS of a listing could leave
+# the time unchanged on a filesystem that stores it coarsely (FAT keeps two
+# seconds), so a folder that recent is never trusted; a file server whose
+# clock runs behind this machine's narrows that guard by the difference.
+_DirKey = tuple[int, int, int] # st_dev, st_ino, st_mtime_ns
+_stems_answers: dict[str, tuple[_DirKey, bool]] = {}
+_no_source_seen: dict[str, _DirKey] = {}
+
+
+def _dir_key(st: os.stat_result) -> _DirKey:
+ return (st.st_dev, st.st_ino, st.st_mtime_ns)
+
+
+class _LibraryView:
+ """One request's look at the jobs folder: JOBS_DIR resolved once, and,
+ for a request about many songs, one listing of it that says which song
+ folders exist and are plain folders, without a call per song."""
+
+ def __init__(self, *, listed: bool) -> None:
+ self.root = JOBS_DIR.resolve()
+ self._entries: dict[str, os.DirEntry] | None = None
+ if listed:
+ try:
+ with os.scandir(JOBS_DIR) as it:
+ self._entries = {entry.name: entry for entry in it}
+ except OSError:
+ self._entries = None
+
+ def song_dir_is_plain(self, job_id: str) -> bool:
+ """Whether the song folder is a plain folder, not a file, link or
+ junction. Raises FileNotFoundError when it is not there.
+
+ Only the kind is read from the listing, never the time: NTFS updates
+ the copy of a folder's times kept in its parent's index lazily, so a
+ listing can show a song folder's old modification time after
+ something was added to it."""
+ if self._entries is None:
+ return _is_plain_dir(os.lstat(JOBS_DIR / job_id))
+ entry = self._entries.get(job_id)
+ if entry is None:
+ raise FileNotFoundError(job_id)
+ if not entry.is_dir(follow_symlinks=False):
+ return False
+ if os.name == "nt":
+ # Carried by the listing on Windows, so this costs no call. A
+ # junction reports itself as a folder and is told apart here.
+ return _is_plain_dir(entry.stat(follow_symlinks=False))
+ return True
+
+
+def _is_plain_dir(st: os.stat_result) -> bool:
+ """A real folder: not a file, and not a link or junction (lstat follows
+ neither, and a junction still reports itself as a folder)."""
+ return stat.S_ISDIR(st.st_mode) and not (
+ getattr(st, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT
+ )
+
+
+def _settled(st: os.stat_result) -> bool:
+ """Changed long enough ago for its modification time to be trusted."""
+ return time.time_ns() - st.st_mtime_ns >= LIBRARY_RACY_WINDOW_NS
+
+
+def _stems_missing_on_disk(job_id: str, jobs_root: Path) -> bool:
+ """The full check: resolves the stems folder, refuses one that leads out
+ of the library (the serving routes refuse it too), and lists it."""
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_relative_to(jobs_root):
+ return True
+ return not stems_dir.is_dir() or not any(stems_dir.iterdir())
+
+
+def _job_files_missing(job: Job, view: _LibraryView | None = None) -> bool:
"""True when a "done" job's stem files are gone from disk: the folder was
deleted or moved outside the app, not just an in-flight relocation (#354),
which is a known, temporary absence and must not flap the library."""
if is_relocating():
return False
- stems_dir = (JOBS_DIR / job.id / "stems").resolve()
- if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ view = view or _LibraryView(listed=False)
+ try:
+ song_dir_plain = view.song_dir_is_plain(job.id)
+ stems = os.lstat(JOBS_DIR / job.id / "stems")
+ except FileNotFoundError:
return True
- return not stems_dir.is_dir() or not any(stems_dir.iterdir())
+ except OSError:
+ return _stems_missing_on_disk(job.id, view.root)
+ if not song_dir_plain or not _is_plain_dir(stems) or not _settled(stems):
+ return _stems_missing_on_disk(job.id, view.root)
+ key = _dir_key(stems)
+ kept = _stems_answers.get(job.id)
+ if kept is not None and kept[0] == key:
+ return kept[1]
+ missing = _stems_missing_on_disk(job.id, view.root)
+ _stems_answers[job.id] = (key, missing)
+ return missing
+
+
+def _fill_source_format(job: Job) -> None:
+ """Uploads made before the format was recorded still have it on disk: the
+ kept source is source., the extension it was validated against. Kept
+ on the job once found, and persisted with it next time. Uploads finished
+ before sources were kept have nothing to find and stay None, which the
+ client shows as the plain note; for those the empty answer is kept beside
+ the song folder's identity and modification time, so the folder is
+ searched again only once an entry in it has been added, removed or
+ renamed, or the folder itself replaced."""
+ try:
+ # The folder's own lstat, not the listing's copy (see song_dir_is_plain).
+ song_dir = os.lstat(JOBS_DIR / job.id)
+ except OSError:
+ song_dir = None
+ key = None
+ if song_dir is not None and _is_plain_dir(song_dir) and _settled(song_dir):
+ key = _dir_key(song_dir)
+ if _no_source_seen.get(job.id) == key:
+ return
+ source = _retained_source(job.id)
+ if source is not None:
+ job.source_format = source.suffix.lower().removeprefix(".")
+ _no_source_seen.pop(job.id, None)
+ elif key is not None:
+ _no_source_seen[job.id] = key
-def _job_state(job: Job) -> dict:
+def _job_state(job: Job, view: _LibraryView | None = None) -> dict:
"""job.to_state() with "done" downgraded to "unavailable" when the stem
files are missing from disk - ground truth for the client, replacing the
old approach of the frontend guessing from a 404 or a disappearance from
the job list, neither of which caught a job whose registry entry survived
but whose stems folder did not."""
if job.source_format is None and (job.source_url or "").startswith("local:"):
- # Uploads made before the format was recorded still have it on disk:
- # the kept source is source., the extension it was validated
- # against. Kept on the job once found, so the disk is asked once per
- # job rather than on every state, and persisted with it next time.
- # Uploads finished before sources were kept have nothing to find and
- # stay None, which the client shows as the plain note.
- source = _retained_source(job.id)
- if source is not None:
- job.source_format = source.suffix.lower().removeprefix(".")
+ _fill_source_format(job)
state = job.to_state()
- if job.status == "done" and _job_files_missing(job):
+ if job.status == "done" and _job_files_missing(job, view):
state["status"] = "unavailable"
return state
@@ -217,19 +376,22 @@ class JobRequest(BaseModel):
# rejected, so a future model with extra stems doesn't break older
# clients pinning the old set.
stems: list[str] | None = None
+ # Lead + Backing (#848): split the vocals once the job is done. Ignored
+ # when the selection has no vocals.
+ vocal_split: bool = False
+
+
+def _note_split_request(job: Job) -> None:
+ """Say in the log that Lead + Backing came with the import, so the log
+ tells a split that was never asked for from one that ran."""
+ if job.vocal_split_wanted:
+ logger.info("[%s] vocal split requested with the import", job.id)
@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."""
- if is_relocating():
- # The stems folder just moved. This process still writes to the old one,
- # so anything accepted now would be orphaned by the restart.
- raise HTTPException(
- status_code=409,
- detail="Restart StemDeck to finish moving your stems folder before importing",
- )
ct = request.headers.get("content-type", "")
if "multipart/form-data" in ct:
return await _create_local_job(request)
@@ -263,11 +425,14 @@ async def _create_youtube_job(request: Request) -> dict[str, str]:
# last thing the pipeline does, and the toggle clears itself as soon
# as the user opens another song.
auto_sections=get_auto_sections(),
+ auto_lyrics=get_auto_lyrics(),
+ vocal_split_wanted=payload.vocal_split and "vocals" in selected,
)
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
raise HTTPException(status_code=503, detail=_URL_QUEUE_FULL_DETAIL)
jobqueue.enqueue(job.id)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ _note_split_request(job)
return {"job_id": job.id}
@@ -298,7 +463,7 @@ async def _create_local_job(request: Request) -> dict[str, str]:
filename: str = getattr(upload, "filename", "") or ""
ext = Path(filename).suffix.lower()
- if ext not in _ALLOWED_EXTS:
+ if ext not in UPLOAD_EXTS:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, .m4a, .ogg, and .opus",
@@ -335,7 +500,9 @@ async def _create_local_job(request: Request) -> dict[str, str]:
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
+ # The error names ffprobe's path and the job folder: log it, do not send it.
+ logger.warning("[%s] could not read the upload's duration", job_id, exc_info=True)
+ raise HTTPException(status_code=422, detail="Could not read file duration") from e
max_duration = get_max_duration_sec()
if duration > max_duration:
@@ -362,12 +529,16 @@ async def _create_local_job(request: Request) -> dict[str, str]:
source_format=ext.removeprefix("."),
audio_tags=audio_tags,
auto_sections=get_auto_sections(),
+ auto_lyrics=get_auto_lyrics(),
+ vocal_split_wanted=form.get("vocal_split") == "true" and "vocals" in selected,
)
+ await asyncio.to_thread(keep_added_time, job, job_dir)
if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL)
jobqueue.enqueue(job.id)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ _note_split_request(job)
return {"job_id": job.id}
@@ -380,9 +551,15 @@ def list_jobs(trashed: Literal["exclude", "include", "only"] = "exclude") -> lis
Trash moved server-side it happily listed tracks the user had deleted on
their desktop hours earlier.
"""
- jobs = sorted(registry_all_jobs().values(), key=lambda j: j.created_at)
+ everything = registry_all_jobs()
+ # Answers kept for songs no longer in the registry would only grow.
+ for kept in (_stems_answers, _no_source_seen):
+ for job_id in [job_id for job_id in list(kept) if job_id not in everything]:
+ kept.pop(job_id, None)
+ jobs = sorted(everything.values(), key=lambda j: j.created_at)
+ view = _LibraryView(listed=True)
return [
- _job_state(job)
+ _job_state(job, view)
for job in jobs
if job.status == "done"
and (trashed == "include" or (job.trashed_at is not None) == (trashed == "only"))
@@ -410,6 +587,7 @@ def trash_job(job_id: str) -> dict:
job.stop_requested = True
_cancel_pipeline(job)
registry_persist(JOBS_DIR)
+ _keep_saved_fields(job)
return {"job_id": job.id, "trashed_at": job.trashed_at}
@@ -422,12 +600,25 @@ def restore_job(job_id: str) -> dict:
if job is None:
raise HTTPException(status_code=404, detail="job not found")
registry_persist(JOBS_DIR)
+ _keep_saved_fields(job)
return {"job_id": job.id, "trashed_at": job.trashed_at}
+def _failed_dir(job_id: str) -> Path:
+ """jobs/failed/, the quarantine _quarantine_failed_job moves a failed
+ job to. JOB_ID_RE rejects "failed", so the quarantine dir can never be
+ addressed as a job id; join it explicitly and re-verify the result stays
+ inside."""
+ failed_dir = (JOBS_DIR / "failed" / job_id).resolve()
+ if not failed_dir.is_relative_to((JOBS_DIR / "failed").resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+ return failed_dir
+
+
@router.post("/{job_id}/extract")
def extract_stopped_job(job_id: str) -> dict:
- """Run a stopped job again from the start, from the source it kept (#748).
+ """Run a stopped job, or a failed upload, again from the start, from the
+ source it kept (#748).
The only way a job stopped by the Trash goes back in the queue. Restore
does not do it: taking a song out of the Trash is not a request to spend
@@ -439,16 +630,46 @@ def extract_stopped_job(job_id: str) -> dict:
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
- if job.status != "stopped":
- raise HTTPException(status_code=409, detail="only a stopped job can be extracted again")
+ # A failed upload too: its file is the one copy, kept by the quarantine
+ # (#868). A failed link is tried again as a new import of the link.
+ failed_upload = job.status == "error" and is_upload(job)
+ if job.status != "stopped" and not failed_upload:
+ raise HTTPException(
+ status_code=409, detail="only a stopped job or a failed upload can be extracted again"
+ )
if job.trashed_at is not None:
raise HTTPException(status_code=409, detail="restore it from the Trash first")
+ job_dir = JOBS_DIR / job_id
+ failed_dir = _failed_dir(job_id)
+ # Not when the quarantine failed and left the folder where it was.
+ moved = failed_upload and not any(job_dir.glob("source.*"))
+ if moved:
+ if not any(failed_dir.glob("source.*")):
+ raise HTTPException(status_code=409, detail="the file is no longer kept")
+ try:
+ failed_dir.rename(job_dir)
+ except OSError as exc:
+ logger.warning("could not move %s back for extract", failed_dir, exc_info=True)
+ raise HTTPException(
+ status_code=409, detail="the kept file could not be moved back"
+ ) from exc
limit = MAX_PENDING_UPLOAD_JOBS if is_upload(job) else MAX_PENDING_URL_JOBS
- if not registry_requeue_if_capacity(job, JOBS_DIR / job_id, limit):
+ if not registry_requeue_if_capacity(job, job_dir, limit):
+ if moved:
+ try:
+ job_dir.rename(failed_dir)
+ except OSError:
+ # Not lost: the next press runs it from jobs/.
+ logger.warning("could not put %s back after a full queue", job_dir, exc_info=True)
raise HTTPException(
status_code=503,
detail=_UPLOAD_QUEUE_FULL_DETAIL if is_upload(job) else _URL_QUEUE_FULL_DETAIL,
)
+ try:
+ (job_dir / "error.txt").unlink(missing_ok=True)
+ except OSError:
+ # The job is queued already; a stale error.txt only costs a few KB.
+ logger.warning("could not delete the old error.txt of %s", job_id, exc_info=True)
registry_persist(JOBS_DIR)
jobqueue.enqueue(job_id)
return job.to_state()
@@ -470,15 +691,120 @@ def set_favorite(job_id: str, body: FavoriteBody) -> dict:
if job is None:
raise HTTPException(status_code=404, detail="job not found")
registry_persist(JOBS_DIR)
+ _keep_saved_fields(job)
return {"job_id": job.id, "favorite": job.favorite}
+# Songs whose loudness was measured again this run, queued for it, or tried and
+# failed: one attempt each, so a song with nothing to measure does not cost an
+# ffmpeg run on every request. A restart tries the failures once more.
+_LOUDNESS_TRIED: set[str] = set()
+# The songs waiting for the one background thread that measures them, oldest
+# first. _LOUDNESS_LOCK covers the set, this queue and _LOUDNESS_WORKER.
+_LOUDNESS_PENDING: deque[str] = deque()
+_LOUDNESS_LOCK = threading.Lock()
+_LOUDNESS_WORKER: threading.Thread | None = None
+
+
+def _loudness_sources(job_id: str) -> tuple[str, list[Path]]:
+ """What to measure for a finished song, and which of LOUDNESS_BASES that
+ is: its kept upload if it has one ("source"), else the sum of its stems
+ ("stems", the mix; the link's source is deleted once separated)."""
+ source = _retained_source(job_id)
+ if source is not None:
+ return "source", [source]
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return "stems", []
+ return "stems", [p for p in (stem_path(stems_dir, n) for n in STEM_NAMES) if p.is_file()]
+
+
+def _queue_loudness_upgrade(job: Job) -> None:
+ """Queue a finished song whose stored loudness comes from an older
+ measurement (see LOUDNESS_VERSION) to be measured again in the background
+ (F11): the request answers with the stored numbers, and the next one has
+ the new. A full queue, or a separation running, leaves the song for a
+ later open."""
+ global _LOUDNESS_WORKER
+ if job.status != "done" or job.loudness_version == LOUDNESS_VERSION:
+ return
+ if jobqueue.running_id() is not None:
+ return
+ with _LOUDNESS_LOCK:
+ if job.id in _LOUDNESS_TRIED or len(_LOUDNESS_PENDING) >= LOUDNESS_UPGRADE_QUEUE_MAX:
+ return
+ _LOUDNESS_TRIED.add(job.id)
+ _LOUDNESS_PENDING.append(job.id)
+ if _LOUDNESS_WORKER is None:
+ worker = threading.Thread(
+ target=_upgrade_queued_loudness, name="loudness-upgrade", daemon=True
+ )
+ worker.start()
+ _LOUDNESS_WORKER = worker
+
+
+def _upgrade_queued_loudness() -> None:
+ """The background thread: one song at a time, then one registry save for
+ all of them. metadata.json gets each song's numbers as they come."""
+ global _LOUDNESS_WORKER
+ measured = False
+ while True:
+ with _LOUDNESS_LOCK:
+ if not _LOUDNESS_PENDING:
+ _LOUDNESS_WORKER = None
+ break
+ job_id = _LOUDNESS_PENDING.popleft()
+ try:
+ measured = _upgrade_loudness(job_id) or measured
+ except Exception:
+ logger.exception("[%s] loudness measurement failed", job_id)
+ if measured:
+ registry_persist(JOBS_DIR)
+
+
+def _upgrade_loudness(job_id: str) -> bool:
+ """Measure one queued song, about 2 s, up to TIMEOUT_ANALYZE. Best effort:
+ on failure the old numbers stay and the song is not retried until a
+ restart. True when the song has new numbers."""
+ job = registry_get(job_id)
+ if job is None or job.status != "done":
+ return False
+ if jobqueue.running_id() is not None:
+ # Never beside a separation (checked when queued, and again here for
+ # one that started since): this song is measured on a later open.
+ with _LOUDNESS_LOCK:
+ _LOUDNESS_TRIED.discard(job_id)
+ return False
+ basis, sources = _loudness_sources(job_id)
+ if not sources or is_relocating():
+ return False
+ lufs, peak_db = measure_loudness(sources)
+ if lufs is None or peak_db is None:
+ return False
+ dr = dynamic_range_db(lufs, peak_db)
+ # _set writes in this order, and a GET can read the job between two
+ # writes. The basis goes first and the version last, so no reader sees
+ # the current version without the basis that says the numbers moved
+ # (#923): the page would take the song as never measured again.
+ fields = {
+ "loudness_remeasured": basis,
+ "lufs": lufs,
+ "peak_db": peak_db,
+ "dynamic_range": dr,
+ "loudness_version": LOUDNESS_VERSION,
+ }
+ _set(job, **fields)
+ _write_metadata_fields(job_id, fields)
+ return True
+
+
@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")
+ _queue_loudness_upgrade(job)
return _job_state(job)
@@ -489,21 +815,33 @@ def cancel_job(job_id: str) -> dict:
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status in FINISHED_STATUSES:
+ # Extract missing stems first: a split or a lyrics timing waiting
+ # behind it would otherwise take the cancel and terminate the fill's
+ # worker under this id. Its stages read a stand-in's flag, which
+ # cancel_fill sets once the fill holds the pipeline lock; before that,
+ # the song's flag stops it from starting, and the process under this
+ # id is someone else's, so it is left alone.
+ if job.stem_fill == "running":
+ job.cancel_requested = True
+ if cancel_fill(job_id):
+ proc = registry_get_proc(job_id, "fill")
+ if proc is not None and proc.poll() is None:
+ proc.terminate()
# A vocal split only ever runs on a done job, so this early return made
# it uncancellable by construction: the flag was never even set, while
# the split held _pipeline_lock and stalled the whole import queue for
# its full duration (#519). Terminating the worker is enough -- the
# split's own error path marks it failed and releases the lock.
- if job.vocal_split == "running":
+ elif job.vocal_split == "running":
job.cancel_requested = True
- proc = registry_get_proc(job_id)
+ proc = registry_get_proc(job_id, "split")
if proc is not None and proc.poll() is None:
proc.terminate()
# A line-by-line timing of its lyrics (POST .../lyrics/retime) stops
# the same way: the run sees its own flag, never the job's, and
# leaves the lyrics as they were.
elif cancel_run(job_id):
- proc = registry_get_proc(job_id)
+ proc = registry_get_proc(job_id, "retime")
if proc is not None and proc.poll() is None:
proc.terminate()
return job.to_state()
@@ -511,6 +849,29 @@ def cancel_job(job_id: str) -> dict:
return job.to_state()
+@router.post("/{job_id}/stop")
+def stop_job(job_id: str) -> dict:
+ """Take a job out of the Queue: stop its extraction and keep the song (#772).
+
+ What the Queue's remove button sends. It used to send cancel, which deletes
+ the job's folder, so the song left the Library as well and never reached
+ the Trash. A stop settles as "stopped" with its files kept, the state a
+ trashed import ends in (#748) but out of the Trash, so the Library shows it
+ with Extract. A link that had not downloaded yet keeps its link, and
+ Extract fetches it again. A job already finished is left as it is.
+ """
+ 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 FINISHED_STATUSES:
+ job.stop_requested = True
+ _cancel_pipeline(job)
+ registry_persist(JOBS_DIR)
+ return job.to_state()
+
+
def _cancel_pipeline(job: Job) -> None:
"""Stop a job that is waiting or running. Shared by cancel and trash (#748).
@@ -558,6 +919,7 @@ class ResplitBody(BaseModel):
trusted, and an empty result falls back to every stem."""
stems: list[str] = Field(default_factory=list, max_length=len(STEM_NAMES))
+ vocal_split: bool = False
def _retained_source(job_id: str) -> Path | None:
@@ -575,7 +937,7 @@ def _retained_source(job_id: str) -> Path | None:
resolved = candidate.resolve()
if (
resolved.is_file()
- and resolved.suffix.lower() in _ALLOWED_EXTS
+ and resolved.suffix.lower() in UPLOAD_EXTS
and resolved.is_relative_to(JOBS_DIR.resolve())
):
return resolved
@@ -675,12 +1037,18 @@ async def resplit_job(job_id: str, body: ResplitBody) -> dict:
work=job.work,
has_lyrics=has_lyrics,
auto_sections=get_auto_sections(),
+ # The song's lyrics came along with it: keep looking for them only if
+ # Detect lyrics is on.
+ auto_lyrics=get_auto_lyrics(),
+ vocal_split_wanted=body.vocal_split and "vocals" in selected,
)
+ await asyncio.to_thread(keep_added_time, new_job, new_dir)
if not registry_register_if_capacity(new_job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(new_dir, ignore_errors=True)
raise HTTPException(status_code=503, detail=_UPLOAD_QUEUE_FULL_DETAIL)
jobqueue.enqueue(new_job.id)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ _note_split_request(new_job)
# source_url comes back because the client cannot derive it: it is
# deliberately not the one it asked with, and the library keys its
# dedup on exactly this string.
@@ -703,45 +1071,284 @@ async def start_vocal_split(job_id: str) -> Response:
raise HTTPException(status_code=409, detail="vocal split already running")
if job.vocal_split == "done":
return JSONResponse(_job_state(job), status_code=202)
+ # Refused, not queued behind it: starting it here cleared the cancel sent
+ # to the fill. No await from here to "running", and the fill re-checks
+ # after its own, so the two never both start.
+ if job.stem_fill == "running":
+ raise JobBusy()
stems_dir = (JOBS_DIR / job_id / "stems").resolve()
if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="job not found")
+ # A cancel is for the run it was sent to. One left over from an earlier
+ # run would otherwise read as this one being cancelled.
+ job.cancel_requested = False
job.vocal_split = "running"
+ job.vocal_split_reason = None
_set(job, stage="Splitting lead/backing vocals...")
+ # Saved before it starts: a restart during the split then reads it as
+ # interrupted, so a page that finds the job asking for Lead + Backing
+ # (vocal_split_wanted) never starts it a second time.
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
try:
async with _pipeline_lock:
- new_names = await asyncio.to_thread(split_vocals, job, stems_dir)
+ # Cancelled while it waited behind an import: never start.
+ if job.cancel_requested:
+ raise VocalSplitError(
+ "vocal split cancelled before it started", code=SPLIT_CANCELLED
+ )
+ logger.info("[%s] vocal split started", job_id)
+ started = time.monotonic()
+ with registry_operation("split"):
+ new_names = await asyncio.to_thread(split_vocals, job, stems_dir)
+ # The peaks are read from the new WAVs first; then the two are
+ # kept the way the song keeps its stems (#783). Neither raises.
+ rms_values = await asyncio.to_thread(
+ merge_stem_peaks, stems_dir, ["vocals", *new_names]
+ )
+ await asyncio.to_thread(store_split_stems, job, stems_dir, new_names)
except Exception as e:
cause = classify_failure("\n".join([*(getattr(e, "tail", None) or []), str(e)]))
logger.warning("[%s] vocal split failed: %s", job_id, e, exc_info=True)
_write_vocal_split_error(stems_dir, cause, getattr(e, "tail", None) or [str(e)])
+ # The song stays done with its Vocals stem, and now says why the pair
+ # is missing (#832). Only the code leaves the server: the exception's
+ # words stay in the log and vocal_split_error.txt.
+ code = getattr(e, "code", None) or (
+ SPLIT_CANCELLED if job.cancel_requested else SPLIT_WORKER_FAILED
+ )
+ vocal_split_status.record(False, code, source="split")
+ job.cancel_requested = False
job.vocal_split = "error"
+ job.vocal_split_reason = code
_set(job, stage="Done")
- registry_persist(JOBS_DIR)
- raise HTTPException(status_code=500, detail="vocal split failed") from e
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ return JSONResponse(
+ {
+ "detail": "vocal split failed",
+ "vocal_split_reason": code,
+ "vocal_split_message": split_message_for(code),
+ },
+ status_code=500,
+ )
+ vocal_split_status.record(True, source="split")
+ logger.info(
+ "[%s] vocal split done in %.1fs: %s",
+ job_id,
+ time.monotonic() - started,
+ ", ".join(new_names),
+ )
existing = {s["name"] for s in job.stems}
for name in new_names:
if name not in existing:
- job.stems.append({"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"})
- # "vocals" rides along so presence_for_split can recover the scale the base
- # stems were normalised against; without it the two new cards would have no
- # percentage to show.
- rms_values = merge_stem_peaks(stems_dir, ["vocals", *new_names])
+ job.stems.append({"name": name, "url": stem_url(job_id, stems_dir, name)})
+ # "vocals" rode along in merge_stem_peaks above so presence_for_split can
+ # recover the scale the base stems were normalised against; without it the
+ # two new cards would have no percentage to show.
extra_presence = presence_for_split(rms_values, job.stem_presence)
if extra_presence:
job.stem_presence = {**(job.stem_presence or {}), **extra_presence}
+ # A cancel that arrived after the worker finished lost the race. Left set,
+ # it would stop the next run on this song that reads the flag.
+ job.cancel_requested = False
job.vocal_split = "done"
_set(job, stage="Done")
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
return JSONResponse(_job_state(job))
+# -- Versions (#804)
+#
+# A version is a named choice of stems over the song's one set of stem files
+# (app/core/versions.py). These endpoints only ever change the song's record:
+# none of them reads, writes or deletes a stem file. Each runs on the event
+# loop with no await between reading the versions and writing them back, so
+# two requests cannot interleave a read-modify-write.
+
+
+class VersionCreateBody(BaseModel):
+ name: str | None = Field(default=None, max_length=VERSION_NAME_MAX_CHARS * 4)
+ stems: list[str] = Field(min_length=1, max_length=len(STEM_NAMES))
+ # Make it the version that opens with the song.
+ select: StrictBool = False
+
+
+class VersionRenameBody(BaseModel):
+ name: str = Field(max_length=VERSION_NAME_MAX_CHARS * 4)
+
+
+def _versions_payload(job: Job) -> dict:
+ versions = versions_of(job)
+ return {"job_id": job.id, "versions": versions, "active_version": active_of(job, versions)}
+
+
+def _version_job(job_id: str, *, ready: bool = True) -> Job:
+ """The job whose versions a request changes, after the id checks.
+ 404 for anything that is not a known job; 409 while it is not done."""
+ 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 ready and job.status != "done":
+ raise HTTPException(status_code=409, detail="the song is not ready")
+ return job
+
+
+def _check_version_id(version_id: str) -> None:
+ if not VERSION_ID_RE.match(version_id):
+ raise HTTPException(status_code=404, detail="version not found")
+
+
+@router.get("/{job_id}/versions")
+async def list_versions(job_id: str) -> dict:
+ """The versions of a song and the one that opens with it."""
+ return _versions_payload(_version_job(job_id, ready=False))
+
+
+@router.post("/{job_id}/versions", status_code=201)
+async def create_version(job_id: str, body: VersionCreateBody) -> dict:
+ """A new version of the song showing `stems`. Nothing is separated or
+ copied on disk: it is settings only."""
+ job = _version_job(job_id)
+ stems = clean_stems(body.stems)
+ if not stems:
+ raise HTTPException(status_code=422, detail="choose at least one known stem")
+ versions = versions_of(job)
+ if len(versions) >= MAX_VERSIONS:
+ raise HTTPException(status_code=409, detail="this song has too many versions")
+ version = make_version(versions, body.name, stems)
+ versions.append(version)
+ active = version["id"] if body.select else active_of(job, versions)
+ _set(job, versions=versions, active_version=active)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ await asyncio.to_thread(_keep_saved_fields, job)
+ return {"version": version, **_versions_payload(job)}
+
+
+@router.patch("/{job_id}/versions/{version_id}")
+async def rename_version(job_id: str, version_id: str, body: VersionRenameBody) -> dict:
+ job = _version_job(job_id)
+ _check_version_id(version_id)
+ name = clean_name(body.name)
+ if name is None:
+ raise HTTPException(status_code=422, detail="a version needs a name")
+ versions = versions_of(job)
+ target = next((v for v in versions if v["id"] == version_id), None)
+ if target is None:
+ raise HTTPException(status_code=404, detail="version not found")
+ target["name"] = name
+ _set(job, versions=versions, active_version=active_of(job, versions))
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ await asyncio.to_thread(_keep_saved_fields, job)
+ return _versions_payload(job)
+
+
+@router.delete("/{job_id}/versions/{version_id}")
+async def delete_version(job_id: str, version_id: str) -> dict:
+ """Forget a version. Its stems stay: every version shares the song's one
+ set of stem files, and no stem file is ever deleted here. The last
+ version cannot be deleted: a song always has one."""
+ job = _version_job(job_id)
+ _check_version_id(version_id)
+ versions = versions_of(job)
+ if not any(v["id"] == version_id for v in versions):
+ raise HTTPException(status_code=404, detail="version not found")
+ if len(versions) == 1:
+ raise HTTPException(status_code=409, detail="a song keeps at least one version")
+ remaining = [v for v in versions if v["id"] != version_id]
+ # The deleted one was opening with the song: the first left does now.
+ _set(job, versions=remaining, active_version=active_of(job, remaining))
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ await asyncio.to_thread(_keep_saved_fields, job)
+ return _versions_payload(job)
+
+
+@router.post("/{job_id}/versions/{version_id}/select")
+async def select_version(job_id: str, version_id: str) -> dict:
+ """Make a version the one that opens with the song."""
+ job = _version_job(job_id)
+ _check_version_id(version_id)
+ versions = versions_of(job)
+ if not any(v["id"] == version_id for v in versions):
+ raise HTTPException(status_code=404, detail="version not found")
+ _set(job, versions=versions, active_version=version_id)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ await asyncio.to_thread(_keep_saved_fields, job)
+ return _versions_payload(job)
+
+
+class FillStemsBody(BaseModel):
+ stems: list[str] = Field(min_length=1, max_length=len(STEM_NAMES))
+
+
+_FILL_REFUSALS = {
+ "no_source": "no source kept for this track",
+ "model": "this song was separated with another model",
+}
+
+
+@router.post("/{job_id}/fill-stems")
+async def fill_missing_stems(job_id: str, body: FillStemsBody) -> JSONResponse:
+ """Extract on a song already in the library (#804): separate only the
+ stems of `stems` it has no file for, with the model and quality it was
+ separated with. The stems it has are never replaced. Answers at once,
+ with nothing separated, when none is missing; otherwise when the run is
+ over, with the song's state."""
+ job = _version_job(job_id)
+ wanted = clean_stems(body.stems)
+ if not wanted:
+ raise HTTPException(status_code=422, detail="choose at least one known stem")
+ job_dir = (JOBS_DIR / job_id).resolve()
+ stems_dir = (job_dir / "stems").resolve()
+ if not stems_dir.is_dir() or not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+ if job.post_hoc_running():
+ raise HTTPException(status_code=409, detail="the song is busy")
+ missing = await asyncio.to_thread(missing_stems, stems_dir, wanted)
+ if not missing:
+ return JSONResponse({"filled": [], "state": _job_state(job)})
+ source = _retained_source(job_id) if (job.source_url or "").startswith("local:") else None
+ if source is None and (job.source_url or "local:").startswith("local:"):
+ raise HTTPException(status_code=409, detail=_FILL_REFUSALS["no_source"])
+ # Checked again here, after the await above: the first of two requests
+ # to get this far is the one that runs, and a split started meanwhile
+ # keeps the song.
+ if job.post_hoc_running():
+ raise HTTPException(status_code=409, detail="the song is busy")
+ # A cancel is for the run it was sent to, as for the vocal split.
+ job.cancel_requested = False
+ job.stem_fill = "running"
+ _set(job, stage="Extracting missing stems...")
+ try:
+ async with _pipeline_lock:
+ with registry_operation("fill"):
+ filled = await asyncio.to_thread(fill_stems, job, job_dir, wanted, source)
+ except FillRefused as e:
+ job.stem_fill = "error"
+ _set(job, stage="Done")
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ raise HTTPException(status_code=409, detail=_FILL_REFUSALS[e.code]) from None
+ except Exception:
+ if not job.cancel_requested:
+ logger.exception("[%s] filling missing stems failed", job_id)
+ job.stem_fill = "error"
+ _set(job, stage="Done")
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ # Stopped by POST .../cancel: every stem the song had is still there.
+ if job.cancel_requested:
+ return JSONResponse({"detail": "the extraction was cancelled"}, status_code=409)
+ return JSONResponse({"detail": "could not extract the missing stems"}, status_code=500)
+ job.stem_fill = "done"
+ _set(job, stage="Done")
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ return JSONResponse({"filled": filled, "state": _job_state(job)})
+
+
_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
-_SECTIONS_WRITE_LOCK = threading.Lock()
def _write_json_atomic(path: Path, data: dict) -> None:
@@ -831,7 +1438,7 @@ def update_sections(job_id: str, body: SectionsBody) -> dict:
raise HTTPException(status_code=404, detail="job not found")
meta_path = job_dir / "metadata.json"
- with _SECTIONS_WRITE_LOCK:
+ with metadata_lock:
meta: dict = {}
try:
if meta_path.is_file():
@@ -882,7 +1489,7 @@ def _write_metadata_fields(job_id: str, fields: dict[str, object]) -> None:
if not meta_path.is_relative_to(JOBS_DIR.resolve()):
return
# The same lock as the sections editor, the other read-modify-write here.
- with _SECTIONS_WRITE_LOCK:
+ with metadata_lock:
try:
if not meta_path.is_file():
return
@@ -892,7 +1499,15 @@ def _write_metadata_fields(job_id: str, fields: dict[str, object]) -> None:
meta.update(fields)
_write_json_atomic(meta_path, meta)
except (OSError, ValueError):
- logger.warning("could not record audio tags in metadata for %s", job_id, exc_info=True)
+ logger.warning("could not update metadata.json for %s", job_id, exc_info=True)
+
+
+def _keep_saved_fields(job: Job) -> None:
+ """Copy what the user set on a song (favourite, Trash, versions) into its
+ metadata.json, so a registry rebuilt from the folders keeps it. Read under
+ the lock, so of two quick changes the later one is what stays."""
+ with metadata_lock:
+ _write_metadata_fields(job.id, saved_fields(job))
@router.post("/{job_id}/audio-tags")
@@ -934,7 +1549,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
if (
job.audio_tags
and (job.artist or not tagged_artist_name(job.audio_tags.get("artist")))
- and lyrics_settled(job_dir)
+ and (lyrics_settled(job_dir) or job.auto_lyrics is False)
and not _wants_identity(job, job.audio_tags)
and not _wants_work(job, job.identity, job.audio_tags)
):
@@ -958,7 +1573,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
tags = await asyncio.wait_for(
asyncio.to_thread(_lookup_audio_tags, job), timeout=TIMEOUT_FETCH_TAGS
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.warning(
"[%s] audio tag lookup timed out after %ds", job_id, TIMEOUT_FETCH_TAGS
)
@@ -994,7 +1609,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
),
timeout=TIMEOUT_IDENTIFY_BACKFILL,
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("[%s] identification timed out", job_id)
work = None
# The work behind a soundtrack, once the identity says which recording
@@ -1011,12 +1626,15 @@ async def refresh_audio_tags(job_id: str) -> dict:
),
timeout=TIMEOUT_WORK_BACKFILL,
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("[%s] work lookup timed out", job_id)
lyrics = None
query = build_query(job, audio_tags=tags, band=artist, identity=identity)
- # Not asked again while LRCLIB's last "nothing" is recent.
- if query is not None and not lyrics_settled(job_dir):
+ # Not asked again while LRCLIB's last "nothing" is recent, and never
+ # for a track imported with Detect lyrics off: its Lyrics tab offers
+ # the lookup instead (POST .../lyrics/lookup), so opening the track
+ # does not fetch what the user turned off.
+ if job.auto_lyrics is not False and query is not None and not lyrics_settled(job_dir):
# find_lyrics never raises, and starts no request past its budget.
try:
lyrics = await asyncio.wait_for(
@@ -1028,7 +1646,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
),
timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1,
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("[%s] lyrics lookup timed out", job_id)
finally:
_TAG_LOOKUPS.discard(job_id)
@@ -1047,7 +1665,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
and not lyrics_path(job_dir).is_file()
and await asyncio.to_thread(keep_answer, job, job_dir, lyrics)
):
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
found: dict[str, object] = {}
if tags and not job.audio_tags:
found["audio_tags"] = tags
@@ -1059,7 +1677,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
found["work"] = work
if found:
_set(job, **found)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
await asyncio.to_thread(_write_metadata_fields, job_id, found)
return {
"audio_tags": job.audio_tags,
@@ -1100,7 +1718,7 @@ def _fingerprint_audio(job: Job) -> list[Path]:
stems_root = (JOBS_DIR / job.id / "stems").resolve()
stems = []
for name in STEM_NAMES:
- path = (stems_root / f"{name}.wav").resolve()
+ path = stem_path(stems_root, name).resolve()
if path.is_file() and path.is_relative_to(JOBS_DIR.resolve()):
stems.append(path)
return stems
@@ -1165,7 +1783,7 @@ def cancelled() -> bool:
try:
# Shielded, so one caller giving up does not cancel another's answer.
answer = await asyncio.wait_for(asyncio.shield(task), DISCOGS_LOOKUP_BUDGET_SEC + 1)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("[%s] Discogs lookup timed out", job_id)
answer = None
except Exception:
@@ -1292,7 +1910,7 @@ async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None)
),
timeout=LYRICS_LOOKUP_BUDGET_SEC + TIMEOUT_LYRICS_LOOKUP + 1,
)
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.info("[%s] lyrics lookup timed out", job_id)
raise HTTPException(status_code=502, detail="lyrics service unreachable") from None
except Exception:
@@ -1307,7 +1925,7 @@ async def lookup_lyrics_route(job_id: str, body: LyricsLookupBody | None = None)
and not lyrics_path(job_dir).is_file()
and await asyncio.to_thread(keep_answer, job, job_dir, answer)
):
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
try:
return await asyncio.to_thread(get_lyrics, job_id)
except HTTPException:
@@ -1465,7 +2083,7 @@ def _retime_ready(job_dir: Path, lyrics: dict | None) -> None:
raise HTTPException(status_code=404, detail="no lyrics")
if not lyric_pairs(lyrics):
raise HTTPException(status_code=409, detail="lyrics have no lines")
- vocals = (job_dir / "stems" / "vocals.wav").resolve()
+ vocals = stem_path(job_dir / "stems", "vocals").resolve()
if not vocals.is_relative_to(JOBS_DIR.resolve()) or not vocals.is_file():
raise HTTPException(status_code=409, detail="no vocals stem")
@@ -1485,9 +2103,10 @@ def report(share: float) -> None:
run.started = True
if cancelled():
raise JobCancelled()
- outcome = await asyncio.to_thread(
- retime_job, job, job_dir, cancelled=cancelled, report=report, lyrics=lyrics
- )
+ with registry_operation("retime"):
+ outcome = await asyncio.to_thread(
+ retime_job, job, job_dir, cancelled=cancelled, report=report, lyrics=lyrics
+ )
run.outcome = outcome
run.state = outcome.state
except JobCancelled:
@@ -1669,12 +2288,7 @@ def get_failure(job_id: str) -> dict:
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
- # JOB_ID_RE rejects "failed", so the quarantine dir can never be addressed
- # as a job id; join it explicitly and re-verify the result stays inside.
- failed_dir = (JOBS_DIR / "failed" / job_id).resolve()
- if not failed_dir.is_relative_to((JOBS_DIR / "failed").resolve()):
- raise HTTPException(status_code=404, detail="job not found")
- path = failed_dir / "error.txt"
+ path = _failed_dir(job_id) / "error.txt"
if not path.is_file():
raise HTTPException(status_code=404, detail="no failure evidence for this job")
@@ -1836,25 +2450,42 @@ def reset_beats(job_id: str) -> dict:
@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."""
+def delete_job(job_id: str, response: Response) -> dict[str, str]:
+ """Delete a completed or failed job and remove its stem files from disk.
+
+ A job emptied from the Trash while the stop its trash sent is still
+ settling answers 202: the page has already let it go, so a refusal left
+ its folder and record on disk with nothing showing them (#748). The id is
+ marked deleted first, and the runner removes the job once it settles
+ (jobqueue.finish_deferred_delete)."""
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 FINISHED_STATUSES:
- raise HTTPException(status_code=409, detail="job is still running")
+ if job.trashed_at is None:
+ raise HTTPException(status_code=409, detail="job is still running")
+ registry_mark_deleted(job_id)
+ registry_persist(JOBS_DIR)
+ # Read after the mark: settled meanwhile, it is deleted below instead.
+ if job.status not in FINISHED_STATUSES:
+ response.status_code = 202
+ return {"job_id": job_id, "status": "deleting"}
+ if job.post_hoc_running():
+ raise JobBusy()
# A timing of its lyrics in flight stops rather than write into a
# directory being removed (it also checks the job is still registered).
- if cancel_run(job_id) and job.vocal_split != "running":
- proc = registry_get_proc(job_id)
+ if cancel_run(job_id):
+ proc = registry_get_proc(job_id, "retime")
if proc is not None and proc.poll() is None:
proc.terminate()
# The worker may still have the vocals open while it stops.
if not wait_run_ended(job_id, RETIME_DELETE_WAIT_SEC):
logger.warning("[%s] lyrics timing still stopping; removing files anyway", job_id)
forget_run(job_id)
+ # A video render of one of its takes writes into the folder about to go.
+ cancel_job_renders(job_id)
# A fingerprint of its audio may still have a file open (Windows).
release_source(job_id)
removed = _rmtree_job(job_id)
@@ -1863,6 +2494,10 @@ def delete_job(job_id: str) -> dict[str, str]:
# re-adopted by restore() on the next start and the track reappears.
registry_mark_deleted(job_id)
registry_remove(job_id)
+ # Its cached backing and rest sums, and the locks it and its takes made:
+ # after the registry, so no new request for it gets that far.
+ forget_backing(job_id)
+ forget_job_locks(job_id)
registry_persist(JOBS_DIR)
if not removed:
raise HTTPException(
diff --git a/app/api/playlist.py b/app/api/playlist.py
index 6a2fd7cd..f09ed725 100644
--- a/app/api/playlist.py
+++ b/app/api/playlist.py
@@ -23,8 +23,12 @@
from app.core.registry import pending_count as registry_pending_count
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.settings import get_auto_sections, get_max_duration_sec, get_playlist_max_items
-from app.core.stems_location import is_relocating
+from app.core.settings import (
+ get_auto_lyrics,
+ get_auto_sections,
+ get_max_duration_sec,
+ get_playlist_max_items,
+)
from app.pipeline import jobqueue
from app.pipeline.download import InvalidPlaylistURL, expand_playlist
@@ -122,12 +126,6 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
except Exception as e:
raise HTTPException(status_code=422, detail=str(e)) from e
- if is_relocating():
- raise HTTPException(
- status_code=409,
- detail="Restart StemDeck to finish moving your stems folder before importing",
- )
-
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)
@@ -142,6 +140,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
# Read once for the batch, so every job in one playlist import agrees, and
# captured now rather than when each job reaches its sections stage.
auto_sections = get_auto_sections()
+ auto_lyrics = get_auto_lyrics()
created: list[dict[str, Any]] = []
for item in items:
job = Job(
@@ -153,13 +152,14 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
title=item["title"] or None,
thumbnail=item.get("thumbnail"),
auto_sections=auto_sections,
+ auto_lyrics=auto_lyrics,
)
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
break # queue filled up mid-loop; report what did land
jobqueue.enqueue(job.id)
created.append({"job_id": job.id, "title": job.title or "", "source_url": item["url"]})
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
return {
"playlist_title": result["playlist_title"],
"jobs": created,
diff --git a/app/api/router.py b/app/api/router.py
index f44d7639..e8e53fe5 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -2,6 +2,7 @@
from fastapi import APIRouter
+from app.api.backing import router as backing_router
from app.api.config import router as config_router
from app.api.discogs import router as discogs_router
from app.api.events import router as events_router
@@ -11,14 +12,21 @@
from app.api.queue import router as queue_router
from app.api.search import router as search_router
from app.api.stems import router as stems_router
+from app.api.suggest import router as suggest_router
+from app.api.takes import router as takes_router
+from app.api.video_encoders import router as video_encoders_router
router = APIRouter()
router.include_router(config_router, tags=["config"])
router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
+router.include_router(takes_router, prefix="/jobs", tags=["takes"])
+router.include_router(backing_router, prefix="/jobs", tags=["jobs"])
router.include_router(events_router, tags=["events"])
router.include_router(stems_router, tags=["stems"])
+router.include_router(suggest_router, tags=["suggest"])
router.include_router(qr_router, tags=["qr"])
router.include_router(queue_router, prefix="/queue", tags=["queue"])
router.include_router(playlist_router, prefix="/playlist", tags=["playlist"])
router.include_router(search_router, prefix="/search", tags=["search"])
router.include_router(discogs_router, prefix="/discogs", tags=["discogs"])
+router.include_router(video_encoders_router, tags=["settings"])
diff --git a/app/api/stems.py b/app/api/stems.py
index b031ee8f..1ed09526 100644
--- a/app/api/stems.py
+++ b/app/api/stems.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import contextlib
import hashlib
import json
import logging
@@ -8,30 +9,79 @@
import re
import subprocess
import tempfile
+import threading
import uuid
import zipfile
from collections import deque
+from collections.abc import Callable
+from dataclasses import asdict
from pathlib import Path
from typing import NamedTuple
+import soundfile as sf
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from starlette.background import BackgroundTask
from app.core.config import (
CACHE_DIR,
+ CLICK_STEM_NAME,
EXTRA_STEM_NAMES,
JOB_ID_RE,
JOBS_DIR,
STEM_NAMES,
+ TAKE_ID_RE,
TIMEOUT_FFMPEG,
+ TRACK_ID_RE,
+ WAV_PLAIN_HEADER_ARGS,
ffmpeg_executable,
)
from app.core.registry import get as registry_get
from app.core.settings import get_export_sample_rate
+from app.core.stem_files import stem_ext, stem_media_type, stem_path
+from app.core.takes import take_dir
+from app.pipeline.audio_encoders import transpose_export, unavailable_detail
from app.pipeline.audio_stats import vocal_envelope
+from app.pipeline.click_render import (
+ CLICK_TO_STEREO,
+ count_in_beats,
+ render_click_wav,
+ render_count_in_wav,
+)
from app.pipeline.click_render import cache_key as click_cache_key
-from app.pipeline.click_render import count_in_beats, render_click_wav, render_count_in_wav
+from app.pipeline.click_sounds import CLICK_SOUND_IDS, DEFAULT_CLICK_SOUND
+from app.pipeline.collect import fill_missing_peaks
+from app.pipeline.fx_filters import (
+ FX_PARAM_MAX_CHARS,
+ FxError,
+ FxRequest,
+ check_section_budget,
+ parse_fx_param,
+ slot_filter,
+ slot_section_count,
+)
+from app.pipeline.mastering import (
+ MASTER_PARAM_MAX_CHARS,
+ Loudness,
+ MasterError,
+ MasterPlan,
+ MasterSettings,
+ MeasureError,
+ comp_ratio,
+ effective_ceiling,
+ measure_file,
+ parse_master_param,
+ plan_chain,
+ plan_graph,
+ run_plan,
+ trim_filter,
+)
+from app.pipeline.take_render import (
+ AAC_ENCODE_ARGS,
+ AAC_ENCODE_ARGS_ALT,
+ EXPORT_LIMITER_LOSSY,
+ export_limiter,
+)
logger = logging.getLogger("stemdeck.api")
@@ -50,6 +100,10 @@
# 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) | frozenset(EXTRA_STEM_NAMES) | {"original"}
+# A recorded take is a lane too, named by its id (^t[0-9a-f]{8}$, never a stem
+# name) and resolved to takes//audio.wav, which is already in the
+# stems' format, aligned to song 0 and the song's length: export applies no
+# offset and no resample to it.
_MIXDOWN_MAX_GAIN = 4.0
# lead_vocals/backing_vocals are a decomposition of vocals, not an independent
# signal -- summing vocals alongside either would double-count the vocal
@@ -60,12 +114,15 @@
# stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps,
# OGG is Vorbis VBR q6 (~192 kbps) — the quality tier matching the MP3 setting.
_ENCODE_ARGS = {
- "wav": ["-c:a", "pcm_s16le"],
+ "wav": ["-c:a", "pcm_s16le", *WAV_PLAIN_HEADER_ARGS],
"mp3": ["-q:a", "2"],
"flac": ["-c:a", "flac"],
"ogg": ["-c:a", "libvorbis", "-q:a", "6"],
}
MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()}
+# Mixdowns whose encoder adds peak of its own, so they end in the true-peak
+# limiter (EXPORT_LIMITER_LOSSY) rather than the bit-transparent one.
+_LOSSY_MIXDOWN_EXTS = frozenset({"mp3", "ogg"})
# Containers whose muxer finishes the file by seeking back to a header it wrote
# earlier, which is impossible on a pipe (#458):
@@ -96,6 +153,31 @@
# grow unboundedly on a long-running server.
_MIXDOWN_CACHE_DIR = CACHE_DIR / "mixdown"
_CLICK_CACHE_DIR = CACHE_DIR / "click"
+
+# The stems zip's click entry (#805) shares the zip's namespace with the stems
+# and the takes. Takes are named "_" or "", never a bare
+# word, so only a stem could collide: refuse to start rather than overwrite one.
+if CLICK_STEM_NAME in (*STEM_NAMES, *EXTRA_STEM_NAMES, "original", "mix"):
+ raise RuntimeError("CLICK_STEM_NAME collides with a stem name")
+
+CLICK_SOUND_QUERY_DESCRIPTION = (
+ "Click sound preset id (static/js/clickSounds.js). Defaults to the classic click, "
+ "which is what a client that predates presets sends."
+)
+
+
+def check_click_sound(sound: str) -> str:
+ """The click sound id, validated against the preset whitelist (#805).
+
+ The id is only ever used as a key into the preset table and a cache-key
+ suffix, never in a path or a command, but it is still refused outright
+ rather than defaulted: a client asking for a sound it cannot have should
+ hear about it, not get the classic click without a word."""
+ if sound not in CLICK_SOUND_IDS:
+ raise HTTPException(status_code=422, detail="unknown click sound")
+ return sound
+
+
_MIXDOWN_CACHE_MAX_FILES = 20
_MIXDOWN_CACHE_MAX_BYTES = 500 * 1024 * 1024 # 500 MB
@@ -109,7 +191,41 @@
# is still reachable, and _prune_mixdown_cache evicts by age rather than
# validity -- so without this a user who exported before upgrading would be
# handed the same broken file back for the same parameters indefinitely.
-_RENDER_CACHE_VERSION = "2"
+#
+# "3": every mixdown now ends in the export limiter (EXPORT_LIMITER), so a mix
+# that summed above -1 dBFS renders differently from the cached clipped file.
+#
+# "4": MP3 and OGG mixdowns now end in the true-peak limiter at -1.5 dBTP
+# (EXPORT_LIMITER_LOSSY) instead of the -1 dBFS sample-peak one.
+#
+# "5": exports take the studio EQ (fx=), and its filters are part of the key.
+#
+# "6": exports take the master (master=): loudness target, glue compressor and
+# true-peak limiter; the master chain with its measured gain is in the key.
+#
+# "7": WAV renders before this carried a LIST/INFO chunk ahead of the data
+# chunk, which strict hardware players reject (WAV_PLAIN_HEADER_ARGS).
+_RENDER_CACHE_VERSION = "7"
+
+# Measured masters (app/pipeline/mastering.py), one small JSON per mix and
+# master setting, so the export, the region export, the "render and listen"
+# excerpt and the loudness readout share one measurement of the whole song.
+# The key holds every input of the measurement; bump the version when the
+# plan's maths change.
+_MASTER_PLAN_DIR = CACHE_DIR / "master"
+_MASTER_PLAN_MAX_FILES = 200
+_MASTER_PLAN_VERSION = "1"
+# At most two songs measured at once on this server; one mix is never
+# measured twice at the same time (the export and the readout often ask
+# together).
+_master_plan_slots = asyncio.Semaphore(2)
+_master_plan_locks: dict[str, asyncio.Lock] = {}
+# A mastered lossy export is decoded and measured; over the ceiling, it is
+# rendered again with its output trimmed (_render_master_verified).
+LOSSY_RETRIES = 3
+LOSSY_RETRY_MARGIN_DB = 0.1
+# How often a loudness request checks that its client is still there.
+_DISCONNECT_POLL_SEC = 0.5
def _mixdown_cache_key(
@@ -121,32 +237,51 @@ def _mixdown_cache_key(
end: float | None,
click_lane: tuple[Path, float] | None = None,
pitches: list[int] | None = None,
+ lane_versions: list[str] | None = None,
+ fx_fragment: str = "",
+ master_fragment: str = "",
+ lanes_fragment: str = "",
) -> str:
"""Every render input is in the key, so a different mixer state, region,
or a Settings change (export sample rate) misses cleanly -- never a stale
hit. Stems are immutable once a job reaches "done", so job_id alone pins
the underlying audio; gains are formatted to match the %.6f precision
already used in the ffmpeg volume filter, so cosmetically-different but
- ffmpeg-equivalent gain strings (e.g. "1" vs "1.0") share a cache entry."""
- raw = "|".join(
- [
- _RENDER_CACHE_VERSION,
- job_id,
- ext,
- ",".join(names),
- ",".join(f"{g:.6f}" for g in gains),
- "" if start is None else f"{start:.6f}",
- "" if end is None else f"{end:.6f}",
- str(get_export_sample_rate()),
- # The click's own cache key already encodes the grid, rate and
- # accent mode, so its filename plus gain fully identifies it.
- "" if click_lane is None else f"{click_lane[0].name}:{click_lane[1]:.6f}",
- # Appended only when something is actually transposed, so every
- # export made before #592 keeps the exact key it already had and
- # the cache survives the change.
- "" if not (pitches and any(pitches)) else "p" + ",".join(str(p) for p in pitches),
- ]
- )
+ ffmpeg-equivalent gain strings (e.g. "1" vs "1.0") share a cache entry.
+
+ `lanes_fragment` (the mode's lanes, which decide what Original holds) is
+ appended only when given, so every key made before lanes= is unchanged."""
+ parts = [
+ _RENDER_CACHE_VERSION,
+ job_id,
+ ext,
+ ",".join(names),
+ ",".join(f"{g:.6f}" for g in gains),
+ "" if start is None else f"{start:.6f}",
+ "" if end is None else f"{end:.6f}",
+ str(get_export_sample_rate()),
+ # The click's own cache key already encodes the grid, rate and
+ # accent mode, so its filename plus gain fully identifies it. ":u":
+ # the click now goes to stereo at unity (#833), so a mix with a click
+ # cached at the old -3 dB misses; a mix without one keeps its key.
+ "" if click_lane is None else f"{click_lane[0].name}:{click_lane[1]:.6f}:u",
+ # Appended only when something is actually transposed, so every
+ # export made before #592 keeps the exact key it already had and
+ # the cache survives the change.
+ "" if not (pitches and any(pitches)) else "p" + ",".join(str(p) for p in pitches),
+ # A take is not immutable like a stem: a nudge re-renders its
+ # audio.wav under the same name. Its size and mtime pin the file
+ # the render read, so a nudged take misses the cache.
+ ",".join(lane_versions or []),
+ # The EQ as the exact filter strings the render runs (RenderFx),
+ # empty when no EQ changes the sound.
+ fx_fragment,
+ # The master chain with its measured gain, empty when off.
+ master_fragment,
+ ]
+ if lanes_fragment:
+ parts.append(lanes_fragment)
+ raw = "|".join(parts)
# Cache key, not a security context -- usedforsecurity=False documents
# that for both readers and static analysis (bandit flags bare sha1()).
return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()
@@ -192,6 +327,60 @@ def _prune_mixdown_cache(cache_dir: Path, keep: Path | None = None) -> None:
logger.debug("mixdown cache prune: could not remove %s", oldest, exc_info=True)
+def _is_recorded_lane(name: str) -> bool:
+ """A take id or a recording track id: lanes resolved to a take's file."""
+ return bool(TAKE_ID_RE.match(name) or TRACK_ID_RE.match(name))
+
+
+def _lane_take_id(job_id: str, name: str) -> str:
+ """The take a recorded lane plays: a take id is itself, a track id is its
+ active take. A track with no take yet is a 422, like an unknown name."""
+ 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")
+ if TRACK_ID_RE.match(name):
+ track = next((t for t in job.tracks if t.get("id") == name), None)
+ if track is None:
+ raise HTTPException(status_code=422, detail="unknown stem requested")
+ if not track.get("active_take_id"):
+ raise HTTPException(status_code=422, detail="track has no take")
+ return str(track["active_take_id"])
+ return name
+
+
+def _take_lane_path(job_id: str, name: str) -> Path:
+ """A take or track lane's audio.wav, with the same guards as a stem. Only
+ a finished take (one listed in job.takes) is a lane; anything else is the
+ same 422 as an unknown stem name."""
+ name = _lane_take_id(job_id, name)
+ job = registry_get(job_id)
+ if not TAKE_ID_RE.match(name) or not any(t.get("id") == name for t in job.takes):
+ raise HTTPException(status_code=422, detail="unknown stem requested")
+ folder = take_dir(job_id, name, JOBS_DIR)
+ if folder is None:
+ raise HTTPException(status_code=404, detail="stem not found")
+ path = (folder / "audio.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 _take_versions(job_id: str, names: list[str]) -> list[str]:
+ """Cache-key fragments for the recorded lanes in `names`, the only lanes
+ whose file can change in place. A track's fragment names its active take,
+ so switching the active take misses the cache."""
+ out: list[str] = []
+ for name in names:
+ if not _is_recorded_lane(name):
+ continue
+ take_id = _lane_take_id(job_id, name)
+ st = _take_lane_path(job_id, name).stat()
+ out.append(f"{name}={take_id}@{st.st_size}:{st.st_mtime_ns}")
+ return out
+
+
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):
@@ -201,7 +390,8 @@ def _validate_stem_path(job_id: str, name: str):
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()
+ # WAV or MP3, whichever this song keeps (#783).
+ path = stem_path(JOBS_DIR / job_id / "stems", name).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
@@ -252,6 +442,7 @@ def _click_lane(
start: float | None = None,
end: float | None = None,
groups: list[int] | None = None,
+ sound: str = DEFAULT_CLICK_SOUND,
) -> _ClickLane | None:
"""Render (or reuse) the click / count-in track for this job as an extra
ffmpeg input, or None when both are off or the job has no beat grid.
@@ -288,6 +479,7 @@ def _click_lane(
start=start,
end=end,
groups=groups,
+ sound=sound,
)
path = _CLICK_CACHE_DIR / f"{key}.wav"
@@ -312,6 +504,7 @@ def _click_lane(
start=start or 0.0,
end=end,
groups=groups,
+ sound=sound,
)
except Exception:
logger.exception("count-in render failed for %s", job_id)
@@ -335,6 +528,7 @@ def _click_lane(
multiplier=multiplier,
accent_mode=accent_mode,
groups=groups,
+ sound=sound,
)
except Exception:
logger.exception("click render failed for %s", job_id)
@@ -409,45 +603,23 @@ def _read_beat_grid(job_id: str) -> dict | None:
PITCH_MIN = -6
PITCH_MAX = 6
-# Probed once per process; the ffmpeg binary cannot change mid-run. None until
-# the first export needs to know.
-_rubberband_cached: bool | None = None
-
def _rubberband_available() -> bool:
"""Whether this ffmpeg can pitch-shift.
- librubberband is GPL and plenty of builds omit it -- the Homebrew ffmpeg on
- a dev machine does, while the bundled macOS build does not -- so this is
- probed rather than assumed. Any probe problem reads as "no": an export that
- silently used a different algorithm would be worse than one that says it
- cannot transpose.
-
- rubberband specifically, not asetrate/atempo. Measured on the bundled build:
- rubberband holds duration to the microsecond across -6..+6 and lands within
- 1 Hz of the target, and a lane whose length moved would desync the mix.
+ librubberband is GPL and plenty of builds omit it: Homebrew's plain ffmpeg
+ does, and so does the per-architecture macOS download the desktop shell
+ installs (#858). So this is probed rather than assumed, by the one probe
+ /api/health reports as `transpose_export` (app/pipeline/audio_encoders.py),
+ cached per ffmpeg binary. No ffmpeg to ask reads as "no", like any other
+ probe problem.
+
+ rubberband specifically, not asetrate/atempo. Measured on a build that has
+ it: rubberband holds duration to the microsecond across -6..+6 and lands
+ within 1 Hz of the target, and a lane whose length moved would desync the
+ mix.
"""
- global _rubberband_cached
- if _rubberband_cached is None:
- _rubberband_cached = False
- try:
- probe = subprocess.run(
- [ffmpeg_executable(), "-hide_banner", "-filters"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- _rubberband_cached = bool(re.search(r"\brubberband\b", probe.stdout))
- except (OSError, subprocess.SubprocessError):
- # Treated as "no pitch support", which is the safe answer, but the
- # two cases are not the same: a build genuinely without the filter
- # and an ffmpeg that would not run at all both surface to the user
- # as "this build cannot transpose". Log which one it was, or that
- # message is the only thing anybody has to go on.
- logger.debug(
- "rubberband probe failed; treating transpose as unavailable", exc_info=True
- )
- return _rubberband_cached
+ return transpose_export() is True
def effective_pitch(name: str, semitones: int) -> int:
@@ -514,7 +686,7 @@ def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
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:
+ if not all(n in _MIXDOWN_NAMES or _is_recorded_lane(n) for n in names):
raise HTTPException(status_code=422, detail="unknown stem requested")
if "vocals" in names and _VOCAL_DECOMPOSITION_NAMES & set(names):
raise HTTPException(
@@ -524,12 +696,303 @@ def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
return names, parsed_gains
+# The longest lanes= value: the six base stem names and their commas.
+LANES_PARAM_MAX_CHARS = 64
+LANES_QUERY_DESCRIPTION = (
+ "The base stems that are lanes of their own in the mode exported, muted or "
+ "not, comma-separated (may be empty). With it, Original is rebuilt as every "
+ "other stem, as playback builds it. Absent: Original is the job's own file."
+)
+
+
+def parse_lanes_param(lanes: str | None) -> frozenset[str] | None:
+ """The `lanes=` value: None when absent (behaviour exactly as before it
+ existed), else the set of base stems with a lane of their own. Unknown or
+ repeated names are refused, never ignored: a dropped name would put its
+ stem into Original and the export would carry it twice."""
+ # Not a string: absent, or the Query default of a route called directly
+ # as a function (tests do), which is absent too.
+ if not isinstance(lanes, str):
+ return None
+ names = [n for n in lanes.split(",") if n]
+ if len(names) != len(set(names)) or any(n not in STEM_NAMES for n in names):
+ raise HTTPException(status_code=422, detail="lanes: unknown or repeated stem")
+ return frozenset(names)
+
+
class _RenderLane(NamedTuple):
- """One ffmpeg input in the mixdown graph: a file, its gain, its shift."""
+ """One ffmpeg input in the mixdown graph: a file, its gain, its shift, and
+ the mixer lane it belongs to (whose EQ it gets; "" for the click)."""
path: Path
gain: float
pitch: int
+ name: str = ""
+
+
+def parse_fx_query(fx: str) -> FxRequest:
+ """The fx= parameter, validated, or a 422 with a safe message."""
+ try:
+ return parse_fx_param(fx)
+ except FxError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from None
+
+
+def _input_rate(path: Path) -> int:
+ """The sample rate an input file is decoded at: the rate the EQ is
+ designed for, because the filter runs on the file's own samples (before
+ any resample to the export rate). Stems and takes are 44.1 kHz; reading the
+ header keeps an older job at another rate right."""
+ try:
+ return int(sf.info(str(path)).samplerate)
+ except Exception:
+ logger.debug("could not read the sample rate of %s; assuming 44100", path, exc_info=True)
+ return 44100
+
+
+class RenderFx(NamedTuple):
+ """The EQ of one render: per input a filter prefix ("" or "biquad=...,"),
+ the master prefix, and the cache key fragment."""
+
+ lanes: list[str]
+ master: str
+ fragment: str
+
+
+def build_render_fx(
+ lane_names: list[str], lane_paths: list[Path], fx_req: FxRequest, extra_sections: int = 0
+) -> RenderFx:
+ """Filter prefixes for each input (by its lane name) and for the mix.
+
+ An input whose lane has no EQ gets "" and the graph is exactly the one
+ built before the EQ existed. The master EQ is designed at the first
+ input's rate, which is the rate amix runs at."""
+ prefixes: list[str] = []
+ sections = extra_sections
+ for name, path in zip(lane_names, lane_paths, strict=True):
+ effects = fx_req.lanes.get(name, []) if name else []
+ chain = slot_filter(effects, _input_rate(path)) if effects else ""
+ sections += slot_section_count(effects)
+ prefixes.append(f"{chain}," if chain else "")
+ master_chain = ""
+ if fx_req.master and lane_paths:
+ master_chain = slot_filter(fx_req.master, _input_rate(lane_paths[0]))
+ sections += slot_section_count(fx_req.master)
+ try:
+ check_section_budget(sections)
+ except FxError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from None
+ master = f"{master_chain}," if master_chain else ""
+ fragment = ""
+ if master or any(prefixes):
+ fragment = "fx:" + "|".join(prefixes) + "#" + master
+ return RenderFx(prefixes, master, fragment)
+
+
+def parse_master_query(master: str) -> MasterSettings | None:
+ """The master= parameter, validated, or a 422 with a safe message."""
+ try:
+ return parse_master_param(master)
+ except MasterError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from None
+
+
+def _read_master_plan(path: Path) -> MasterPlan | None:
+ try:
+ return MasterPlan.from_json(json.loads(path.read_text(encoding="utf-8")))
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError, KeyError, TypeError):
+ logger.debug("unreadable master plan %s", path, exc_info=True)
+ return None
+
+
+def _write_master_plan(path: Path, plan: MasterPlan) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ tmp.write_text(json.dumps(plan.to_json()), encoding="utf-8")
+ os.replace(tmp, path)
+ try:
+ entries = sorted(
+ (p for p in path.parent.iterdir() if p.is_file() and not p.name.startswith(".")),
+ key=lambda p: p.stat().st_mtime,
+ )
+ for old in entries[: max(0, len(entries) - _MASTER_PLAN_MAX_FILES)]:
+ if old != path:
+ old.unlink(missing_ok=True)
+ except OSError:
+ logger.debug("master plan prune failed", exc_info=True)
+
+
+async def master_plan(
+ job_id: str,
+ lane_paths: list[Path],
+ lane_chains: list[str],
+ master_eq: str,
+ settings: MasterSettings,
+ lossy: bool,
+ versions: list[str],
+) -> tuple[MasterPlan, int]:
+ """The measured master of the whole song for these lanes, and the rate the
+ master chain runs at (the first input's, which amix runs at).
+
+ ``lane_chains[i]`` is input i's chain as the export runs it (EQ, pitch
+ shift, gain), ``master_eq`` the master EQ prefix, ``versions`` the take
+ fragments that pin files which can change in place. Cached on disk; a
+ mix measured once is never measured again, and never twice at once."""
+ rate = await asyncio.to_thread(_input_rate, lane_paths[0])
+ ceiling = effective_ceiling(settings, lossy)
+ inputs: list[str] = []
+ for p in lane_paths:
+ inputs += ["-i", str(p)]
+ graph = plan_graph(lane_chains, master_eq)
+ raw = "|".join(
+ [
+ _MASTER_PLAN_VERSION,
+ job_id,
+ ";".join(str(p) for p in lane_paths),
+ graph,
+ ",".join(versions),
+ settings.fragment(),
+ f"{ceiling:.1f}",
+ str(rate),
+ ]
+ )
+ key = hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()
+ path = _MASTER_PLAN_DIR / f"{key}.json"
+ cached = await asyncio.to_thread(_read_master_plan, path)
+ if cached is not None:
+ return cached, rate
+ lock = _master_plan_locks.setdefault(key, asyncio.Lock())
+ try:
+ async with lock:
+ cached = await asyncio.to_thread(_read_master_plan, path)
+ if cached is not None:
+ return cached, rate
+ async with _master_plan_slots:
+ try:
+ plan = await run_plan(inputs, graph, settings, ceiling, rate)
+ except MeasureError as exc:
+ logger.warning("master measurement failed [job=%s]: %s", job_id, exc)
+ raise HTTPException(
+ status_code=500, detail="loudness measurement failed"
+ ) from None
+ await asyncio.to_thread(_write_master_plan, path, plan)
+ return plan, rate
+ finally:
+ if not lock.locked():
+ _master_plan_locks.pop(key, None)
+
+
+def master_headers(plan: MasterPlan, out: Loudness | None = None) -> dict[str, str]:
+ """The measured loudness before and after the master, for API clients.
+ ``out`` is the decoded file's own measurement (lossy exports), otherwise
+ the plan's (lossless output is the plan's signal, quantised)."""
+
+ def fmt(v: float | None) -> str:
+ return "none" if v is None else f"{v:.2f}"
+
+ post = out or plan.post
+ return {
+ "X-StemDeck-Loudness-Pre": f"i={fmt(plan.pre.i)};tp={fmt(plan.pre.tp)}",
+ "X-StemDeck-Loudness-Post": f"i={fmt(post.i)};tp={fmt(post.tp)}",
+ "X-StemDeck-Master-Gain": f"{plan.gain_db:.2f}",
+ }
+
+
+def _output_note_path(cache_key: str) -> Path:
+ return _MASTER_PLAN_DIR / f"out-{cache_key}.json"
+
+
+def _read_master_output(cache_key: str) -> Loudness | None:
+ try:
+ data = json.loads(_output_note_path(cache_key).read_text(encoding="utf-8"))
+ return Loudness(**data)
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError, TypeError):
+ logger.debug("unreadable master output note %s", cache_key, exc_info=True)
+ return None
+
+
+def _write_master_output(cache_key: str, out: Loudness) -> None:
+ path = _output_note_path(cache_key)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ tmp.write_text(json.dumps(asdict(out)), encoding="utf-8")
+ os.replace(tmp, path)
+
+
+async def _render_master_verified(
+ build_cmd: Callable[[float, bool], list[str]],
+ suffix: str,
+ context: str,
+ ceiling: float,
+ cache_path: Path | None,
+ cache_key: str | None,
+ has_alt_encoder: bool = False,
+) -> tuple[Path, Loudness]:
+ """Render a mastered lossy export, decode it and measure its true peak.
+
+ The master limits the signal at the lossy ceiling, but an encoder adds
+ peak of its own: up to 1.0 dB for MP3 and OGG on dense limited material,
+ and the AAC encoder sometimes sends a single block several dB over (task F
+ measured +3.6 dBFS on one song at a -1.6 dB limit, not monotonic in the
+ input level). So the file is checked as a player would decode it, and when
+ it is over the user's ceiling the master's output is trimmed by the
+ overshoot plus LOSSY_RETRY_MARGIN_DB and the file rendered again, up to
+ LOSSY_RETRIES times. With ``has_alt_encoder`` (AAC) the first retry keeps
+ the level and switches to the alternative encoder settings instead: an AAC
+ glitch is one block, and trimming the whole song by it cost up to 4.9 LU
+ (measured), where the fast coder stayed within 0.8 dB of its input on
+ every song tried. Returns the file (the cache entry when ``cache_path``
+ is given, otherwise a temp file the caller removes) and its measurement."""
+ trim = 0.0
+ alt = False
+ path: Path | None = None
+ out = Loudness(None, None)
+ for attempt in range(LOSSY_RETRIES + 1):
+ path = await _render_to_file(build_cmd(trim, alt), suffix, context=context)
+ try:
+ out = await measure_file(str(path))
+ except MeasureError as exc:
+ path.unlink(missing_ok=True)
+ logger.warning("master output measurement failed [%s]: %s", context, exc)
+ raise HTTPException(status_code=500, detail="export failed") from None
+ if out.tp is None or out.tp <= ceiling:
+ break
+ if attempt == LOSSY_RETRIES:
+ logger.warning(
+ "mastered export still %.2f dBTP over its ceiling after %d retries [%s]",
+ out.tp - ceiling,
+ LOSSY_RETRIES,
+ context,
+ )
+ break
+ if has_alt_encoder and not alt:
+ alt = True
+ else:
+ trim += out.tp - ceiling + LOSSY_RETRY_MARGIN_DB
+ path.unlink(missing_ok=True)
+ assert path is not None
+ if cache_path is not None and cache_key is not None:
+ os.replace(path, cache_path)
+ _prune_mixdown_cache(cache_path.parent, keep=cache_path)
+ await asyncio.to_thread(_write_master_output, cache_key, out)
+ path = cache_path
+ return path, out
+
+
+def master_plan_view(plan: MasterPlan) -> dict:
+ """The plan as the studio reads it (the readout and the live preview)."""
+ view = plan.to_json()
+ view["comp_ratio"] = comp_ratio(plan.comp) if plan.comp_threshold_db is not None else None
+ return view
+
+
+def _lane_chain(eq: str, pitch: int, gain: float) -> str:
+ """One input's chain as every export runs it: EQ, pitch shift, gain."""
+ return f"{eq}{_pitch_filter(pitch)}volume={gain:.6f}"
def _expand_render_lanes(
@@ -537,6 +1000,8 @@ def _expand_render_lanes(
names: list[str],
gains: list[float],
pitches: list[int],
+ remove: frozenset[str] = frozenset(),
+ lanes: frozenset[str] | None = None,
) -> tuple[list[_RenderLane], list[str]]:
"""Lanes to render, with "original" rebuilt from its parts when it is
transposed. Returns the lanes and the names of any lane left unpitched.
@@ -552,8 +1017,25 @@ def _expand_render_lanes(
-- the lane cannot be taken apart, so it stays at its original key. That is
deliberately the same conservative choice playback makes: melodic content
stays in the old key rather than the drums being resampled.
+
+ `remove` (Streamer Mode's "I play": the stems the musician plays) is left
+ out: a lane of that name is dropped, and "original" is always rebuilt
+ from its components without it, as playback does (playbackStems.js), so
+ the export carries exactly the backing that was heard. A job that cannot
+ be taken apart then gets a 422 rather than the removed part back.
+
+ `lanes` (the mode's own lanes: every base stem shown as a lane, muted or
+ not) makes Original exactly what playback plays: every base stem that is
+ neither a lane nor removed. Without it Original is the complement of the
+ *requested* names, and the client leaves muted and solo-silenced lanes out
+ of the request, so a transposed export with a muted lane put that stem
+ back inside Original (tests/test_mode_stems.py). With it, a mode whose
+ lanes differ from the job's selection never exports a stem twice: at key 0
+ the job's original.wav is used only when it holds exactly that set.
"""
- lanes: list[_RenderLane] = []
+ if lanes is not None:
+ return _expand_mode_lanes(job_id, names, gains, pitches, remove, lanes)
+ lanes_out: list[_RenderLane] = []
unpitched: list[str] = []
selected = set(names)
# A completed lead/backing split stands in for the base vocals source, so
@@ -567,34 +1049,130 @@ def _expand_render_lanes(
# never produced would 404 untransposed and be synthesised from
# components when transposed, so the same URL was valid or not
# depending on the pitch attached to it.
+ if _is_recorded_lane(name):
+ lanes_out.append(_RenderLane(_take_lane_path(job_id, name), gain, pitch, name))
+ continue
+ if name in remove:
+ continue
path = _validate_stem_path(job_id, name)
- if name != "original" or pitch == 0:
- lanes.append(_RenderLane(path, gain, pitch))
+ if name != "original" or (pitch == 0 and not remove):
+ lanes_out.append(_RenderLane(path, gain, pitch, name))
continue
- components = [n for n in STEM_NAMES if n not in selected]
+ components = [n for n in STEM_NAMES if n not in selected and n not in remove]
+ if not components:
+ # Everything the lane held is removed or has a lane of its own.
+ continue
# Deliberately not `path`: that name holds the validated "original.wav"
# this lane falls back to, and reusing it here left the fallback
# pointing at whichever component was tried last.
- paths: list[Path] = []
- for comp in components:
- comp_path = (JOBS_DIR / job_id / "stems" / f"{comp}.wav").resolve()
- if not comp_path.is_file() or not comp_path.is_relative_to(JOBS_DIR.resolve()):
- paths = []
- break
- paths.append(comp_path)
+ paths = _component_paths(job_id, components)
if not paths:
- lanes.append(_RenderLane(path, gain, 0))
+ if remove:
+ raise HTTPException(
+ status_code=422, detail="this song's stems cannot be mixed without one part"
+ )
+ lanes_out.append(_RenderLane(path, gain, 0, name))
unpitched.append(name)
continue
# Each component carries the lane's own gain; amix sums them back to
# what the single file would have been. effective_pitch keeps drums at 0.
for comp, path in zip(components, paths, strict=True):
- lanes.append(_RenderLane(path, gain, effective_pitch(comp, pitch)))
+ lanes_out.append(_RenderLane(path, gain, effective_pitch(comp, pitch), name))
- return lanes, unpitched
+ return lanes_out, unpitched
+
+
+def _component_paths(job_id: str, components: list[str]) -> list[Path]:
+ """The resolved stem file of every component, or [] when one is missing
+ or resolves outside the jobs folder: Original is then not rebuilt."""
+ jobs_root = JOBS_DIR.resolve()
+ stems = JOBS_DIR / job_id / "stems"
+ paths: list[Path] = []
+ for comp in components:
+ comp_path = stem_path(stems, comp).resolve()
+ if not comp_path.is_file() or not comp_path.is_relative_to(jobs_root):
+ return []
+ paths.append(comp_path)
+ return paths
+
+
+def _job_original_set(job_id: str) -> frozenset[str] | None:
+ """The stems the job's own original.wav holds (make_original_track: every
+ base stem not selected), or None when it has no such file."""
+ job = registry_get(job_id)
+ path = stem_path(JOBS_DIR / job_id / "stems", "original").resolve()
+ if job is None or not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ return None
+ selected = set(job.selected_stems)
+ return frozenset(n for n in STEM_NAMES if n not in selected)
+
+
+def _expand_mode_lanes(
+ job_id: str,
+ names: list[str],
+ gains: list[float],
+ pitches: list[int],
+ remove: frozenset[str],
+ lanes: frozenset[str],
+) -> tuple[list[_RenderLane], list[str]]:
+ """_expand_render_lanes for a request that names its mode's lanes.
+
+ Original = every base stem that is neither in `lanes` nor removed, always,
+ whatever was muted. A base stem requested as a lane must be in `lanes`:
+ otherwise it would also be inside Original and play twice."""
+ laned = set(lanes)
+ if {"lead_vocals", "backing_vocals"} & set(names):
+ # The split halves are the Voice lane: vocals is not in Original.
+ laned.add("vocals")
+ for name in names:
+ if name in STEM_NAMES and name not in laned and name not in remove:
+ raise HTTPException(status_code=422, detail="lanes: a requested stem is not a lane")
+ components = [n for n in STEM_NAMES if n not in laned and n not in remove]
+ own_set = _job_original_set(job_id)
+
+ out: list[_RenderLane] = []
+ unpitched: list[str] = []
+ for name, gain, pitch in zip(names, gains, pitches, strict=True):
+ if _is_recorded_lane(name):
+ out.append(_RenderLane(_take_lane_path(job_id, name), gain, pitch, name))
+ continue
+ if name in remove:
+ continue
+ if name != "original":
+ out.append(_RenderLane(_validate_stem_path(job_id, name), gain, pitch, name))
+ continue
+ # The job id and state, as _validate_stem_path checks them; the file
+ # itself may not exist (a song extracted with all six 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")
+ if not components:
+ continue
+ if pitch == 0 and own_set is not None and own_set == frozenset(components):
+ # The job's own file holds exactly this set: the export made
+ # before lanes= existed, byte for byte.
+ out.append(_RenderLane(_validate_stem_path(job_id, "original"), gain, 0, name))
+ continue
+ paths = _component_paths(job_id, components)
+ if not paths:
+ if own_set is not None and own_set == frozenset(components):
+ # An older job whose parts are gone: its file, unpitched,
+ # the conservative choice playback makes.
+ path = _validate_stem_path(job_id, "original")
+ out.append(_RenderLane(path, gain, 0, name))
+ unpitched.append(name)
+ continue
+ raise HTTPException(
+ status_code=422, detail="this song's stems cannot be mixed for these lanes"
+ )
+ for comp, path in zip(components, paths, strict=True):
+ out.append(_RenderLane(path, gain, effective_pitch(comp, pitch), name))
+ return out, unpitched
async def _drain_stderr(stream: asyncio.StreamReader, sink: deque[str]) -> None:
@@ -658,7 +1236,7 @@ async def _stream_ffmpeg(cmd: list[str], context: str = "", cache_path: Path | N
await proc.wait()
try:
await asyncio.wait_for(drain_task, timeout=5)
- except (TimeoutError, asyncio.TimeoutError):
+ except TimeoutError:
drain_task.cancel()
if finished and proc.returncode != 0:
logger.warning(
@@ -721,12 +1299,12 @@ async def _render_to_file(
try:
try:
await asyncio.wait_for(proc.wait(), timeout=TIMEOUT_FFMPEG)
- except (TimeoutError, asyncio.TimeoutError):
+ except TimeoutError:
proc.kill()
await proc.wait()
try:
await asyncio.wait_for(drain_task, timeout=5)
- except (TimeoutError, asyncio.TimeoutError):
+ except TimeoutError:
drain_task.cancel()
if proc.returncode != 0:
logger.warning(
@@ -755,9 +1333,10 @@ async def _render_to_file(
def _unlink_later(path: Path) -> None:
- """Drop a rendered temp file once its response has been sent.
+ """Drop a rendered temp file once its response has been sent, or once its
+ render has failed.
- Only ever attached to an uncached render. A cached one returns the cache
+ Only ever used on an uncached render or a zip. A cached one returns the cache
entry itself, and deleting that would throw away the render the cache
exists to keep."""
try:
@@ -766,6 +1345,26 @@ def _unlink_later(path: Path) -> None:
logger.debug("could not remove rendered export %s", path, exc_info=True)
+class _ZipTemp:
+ """The file a stems zip is built in. It goes once the request has given it
+ up (a failed build, or a cancel: the client or the server going away) and
+ the build thread has stopped writing it, whichever comes second. A cancel
+ does not stop the thread, so the request alone cannot tell when the file
+ is free, and on Windows removing it sooner fails."""
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self._lock = threading.Lock()
+ self._ended: set[str] = set()
+
+ def end(self, side: str) -> None:
+ with self._lock:
+ self._ended.add(side)
+ both = self._ended == {"build", "request"}
+ if both:
+ _unlink_later(self.path)
+
+
async def _ensure_cached_mp3(src: Path) -> Path:
"""Transcode `src` (a stem WAV) to a sibling `.mp3`, cached on disk.
Re-encoding a full song on every request is the slow part of loading a track
@@ -795,7 +1394,7 @@ async def _ensure_cached_mp3(src: Path) -> Path:
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG)
- except (TimeoutError, asyncio.TimeoutError):
+ except TimeoutError:
proc.kill()
await proc.wait()
tmp.unlink(missing_ok=True)
@@ -810,7 +1409,35 @@ async def _ensure_cached_mp3(src: Path) -> Path:
return dest
-def _peaks_response(job_id: str, request: Request) -> Response:
+# One build at a time per job: the catalog and the player can both ask for
+# the peaks of a song that has none in the same moment.
+_peaks_build_locks: dict[str, asyncio.Lock] = {}
+
+
+async def _build_missing_peaks(job_id: str, job_takes: list[dict]) -> None:
+ """Build peaks.json for a finished job that has none (see
+ fill_missing_peaks). Failures leave the job as it was: the studio then
+ decodes the stems in the browser, as it always did for such a job."""
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_dir() or not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return
+ lock = _peaks_build_locks.setdefault(job_id, asyncio.Lock())
+ async with lock:
+ if (stems_dir / "peaks.json").is_file():
+ return
+ sources: dict[str, Path] = {}
+ for take in job_takes:
+ folder = take_dir(job_id, str(take.get("id") or ""))
+ if folder is not None:
+ sources[take["id"]] = folder / "audio.wav"
+ try:
+ await asyncio.to_thread(fill_missing_peaks, stems_dir, sources)
+ except Exception:
+ logger.exception("could not build peaks for %s", job_id)
+ _peaks_build_locks.pop(job_id, None)
+
+
+async def _peaks_response(job_id: str, request: Request) -> Response:
"""Pre-computed waveform peaks for every stem, revalidated on each use.
Not cacheable forever, because it is not fixed once a job is done. The
@@ -823,6 +1450,10 @@ def _peaks_response(job_id: str, request: Request) -> Response:
`no-cache` keeps a copy but asks before using it. The ETag makes asking
cheap: an unchanged file is a 304 with no body, which FileResponse does not
do by itself.
+
+ A finished job with no peaks.json (older or hand-made) has it built on
+ this first request, about a second for a three minute song, so it opens
+ on the streaming engine like any other. 404 only when that fails.
"""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
@@ -830,6 +1461,8 @@ def _peaks_response(job_id: str, request: Request) -> Response:
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():
+ await _build_missing_peaks(job_id, list(job.takes or []))
if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="peaks not found")
st = path.stat()
@@ -858,7 +1491,7 @@ async def get_peaks(job_id: str, request: Request) -> Response:
them, and this one is known before the job's state is, so the peaks fetch
can still start in parallel with it.
"""
- return _peaks_response(job_id, request)
+ return await _peaks_response(job_id, request)
@router.get("/jobs/{job_id}/stems/peaks.json")
@@ -868,7 +1501,7 @@ async def get_stem_peaks(job_id: str, request: Request) -> Response:
Kept for anything that still asks here. It no longer claims to be
immutable, since that was never true once splits could rewrite it.
"""
- return _peaks_response(job_id, request)
+ return await _peaks_response(job_id, request)
# The vocals stem's level every 40ms: fine enough to find the gap between two
@@ -912,7 +1545,7 @@ async def get_vocal_envelope(job_id: str) -> Response:
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
stems_dir = (JOBS_DIR / job_id / "stems").resolve()
- src = stems_dir / "vocals.wav"
+ src = stem_path(stems_dir, "vocals")
dest = stems_dir / _VOCAL_ENVELOPE_FILE
jobs_root = JOBS_DIR.resolve()
if not src.is_file() or not src.is_relative_to(jobs_root) or not dest.is_relative_to(jobs_root):
@@ -966,17 +1599,17 @@ async def get_stem(
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"),
- click: bool = Query(default=False, description="Mix the click track into the export"),
- click_mult: float = Query(default=1.0, description="Click rate: 0.5, 1 or 2"),
- click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"),
- click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"),
) -> FileResponse | StreamingResponse:
- """Download a WAV stem. Optional ?start=&end= trims to a time region."""
+ """A stem, as the song stores it: WAV, or MP3 for a song stored as MP3
+ (#783), with its own type and file name whatever this URL says. Optional
+ ?start=&end= trims to a time region, always rendered as WAV."""
path = _validate_stem_path(job_id, name)
if start is None and end is None:
return FileResponse(
- path, media_type="audio/wav", filename=_stem_download_name(job_id, name, "wav")
+ path,
+ media_type=stem_media_type(path),
+ filename=_stem_download_name(job_id, name, stem_ext(path)),
)
if start is None or end is None or start >= end:
@@ -999,6 +1632,7 @@ async def get_stem(
str(end - start),
"-c:a",
"pcm_s16le",
+ *WAV_PLAIN_HEADER_ARGS,
"-f",
"wav",
]
@@ -1116,6 +1750,9 @@ async def get_mixdown(
max_length=64,
description='Beat grouping for an explicit meter, e.g. "3+2+2". Blank uses the default.',
),
+ click_sound: str = Query(
+ default=DEFAULT_CLICK_SOUND, max_length=32, description=CLICK_SOUND_QUERY_DESCRIPTION
+ ),
count_in: int = Query(
# Ceiling matches MAX_COUNT_IN_BARS in static/js/state.js, the longest
# count-in the UI can ask for (#587). The lead-in it prepends is
@@ -1127,6 +1764,25 @@ async def get_mixdown(
le=4,
description="Count-in bars before the audio (0 off)",
),
+ fx: str = Query(
+ default="",
+ max_length=FX_PARAM_MAX_CHARS,
+ description=(
+ "The studio EQ: base64url JSON {v: 1, lanes: {name: [eq]}, master: [eq]}. "
+ "Empty means no EQ. See app/pipeline/fx_filters.py."
+ ),
+ ),
+ master: str = Query(
+ default="",
+ max_length=MASTER_PARAM_MAX_CHARS,
+ description=(
+ "The master: base64url JSON {v: 1, target: LUFS or null, ceiling: dBTP, "
+ "comp: 0..1}. Empty means no master. See app/pipeline/mastering.py."
+ ),
+ ),
+ lanes: str | None = Query(
+ default=None, max_length=LANES_PARAM_MAX_CHARS, description=LANES_QUERY_DESCRIPTION
+ ),
) -> FileResponse | StreamingResponse:
"""Render a mixdown of the given lanes at the given gains, streamed as WAV,
MP3, FLAC, or OGG. Mirrors the studio mixer (per-stem volume, mute, solo) so the
@@ -1147,6 +1803,10 @@ async def get_mixdown(
names, parsed_gains = _parse_lane_gains(stems, gains)
parsed_pitches = _parse_pitches(pitches, names)
+ fx_req = parse_fx_query(fx)
+ master_settings = parse_master_query(master)
+ lane_set = parse_lanes_param(lanes)
+ check_click_sound(click_sound)
if (start is None) != (end is None) or (start is not None and start >= end):
raise HTTPException(
status_code=422,
@@ -1167,8 +1827,44 @@ async def get_mixdown(
# stem -- deliberately before the cache lookup below, so a deleted or
# not-yet-done job 404s the same way it always has instead of serving a
# stale cache entry from before the job was removed.
- render_lanes, unpitched = _expand_render_lanes(job_id, names, parsed_gains, parsed_pitches)
+ render_lanes, unpitched = _expand_render_lanes(
+ job_id, names, parsed_gains, parsed_pitches, lanes=lane_set
+ )
+ # Like the transpose check, for an encoder this ffmpeg lacks: libvorbis is
+ # optional (#824) and the UI disables OGG without it. A stale page or a
+ # direct call gets this instead of an empty streamed file. After the job
+ # checks, so an unknown job still answers 404.
+ unavailable = await asyncio.to_thread(unavailable_detail, ext)
+ if unavailable:
+ raise HTTPException(status_code=422, detail=unavailable)
paths = [lane.path for lane in render_lanes]
+ # Each lane's EQ as biquad filters with the live engine's coefficients;
+ # "" for a lane without EQ, so its graph is unchanged.
+ render_fx = await asyncio.to_thread(
+ build_render_fx, [lane.name for lane in render_lanes], paths, fx_req
+ )
+
+ # The master: measured on the whole song (so a region gets the full
+ # master's gain), then its chain replaces the export limiter.
+ lossy = ext in _LOSSY_MIXDOWN_EXTS
+ plan: MasterPlan | None = None
+ limiter = export_limiter(lossy=lossy)
+ master_fragment = ""
+ if master_settings is not None:
+ plan, rate_in = await master_plan(
+ job_id,
+ paths,
+ [
+ _lane_chain(render_fx.lanes[i], lane.pitch, lane.gain)
+ for i, lane in enumerate(render_lanes)
+ ],
+ render_fx.master,
+ master_settings,
+ lossy,
+ _take_versions(job_id, names),
+ )
+ limiter = plan_chain(plan, rate_in)
+ master_fragment = "m:" + limiter
media_type = MIXDOWN_MEDIA_TYPES[ext]
click_lane = await asyncio.to_thread(
@@ -1182,21 +1878,35 @@ async def get_mixdown(
start=start,
end=end,
groups=_parse_groups(click_groups, click_accent),
+ sound=click_sound,
)
cache_key = _mixdown_cache_key(
- job_id, ext, names, parsed_gains, start, end, click_lane, parsed_pitches
+ job_id,
+ ext,
+ names,
+ parsed_gains,
+ start,
+ end,
+ click_lane,
+ parsed_pitches,
+ _take_versions(job_id, names),
+ render_fx.fragment,
+ master_fragment,
+ lanes_fragment="" if lane_set is None else "l:" + ",".join(sorted(lane_set)),
)
+ headers = {
+ "Content-Disposition": (
+ f'attachment; filename="{_mixdown_download_name(job_id, ext, start is not None)}"'
+ ),
+ **(master_headers(plan) if plan is not None else {}),
+ }
cache_path = _MIXDOWN_CACHE_DIR / f"{cache_key}.{ext}"
if cache_path.is_file():
- return FileResponse(
- cache_path,
- media_type=media_type,
- headers={
- "Content-Disposition": (
- f'attachment; filename="{_mixdown_download_name(job_id, ext, start is not None)}"'
- )
- },
- )
+ if plan is not None and lossy:
+ out = await asyncio.to_thread(_read_master_output, cache_key)
+ if out is not None:
+ headers.update(master_headers(plan, out))
+ return FileResponse(cache_path, media_type=media_type, headers=headers)
pre_seek = ["-ss", str(start)] if start is not None else []
@@ -1224,42 +1934,69 @@ async def get_mixdown(
# sum with amix (normalize=0 keeps levels faithful, matching collect.py). The
# click lane is never delayed -- its lead-in is baked in. A single audible
# lane skips amix (a 1-input amix is a no-op).
+ #
+ # Then the export limiter, always. Six stems at unity summed to +0.9 dBFS
+ # on a real song and the encoder clamped 77 samples to full scale. For WAV
+ # and FLAC the limiter holds the sample peak at -1 dBFS and is
+ # bit-transparent below it, so an export that never reached the ceiling is
+ # unchanged (EXPORT_LIMITER). MP3 and OGG get the true-peak limiter at
+ # -1.5 dBTP, because their encoders add peak of their own
+ # (EXPORT_LIMITER_LOSSY). With a master, its chain (compressor, gain,
+ # true-peak limiter) takes the limiter's place (set above).
filters = []
for i, lane in enumerate(render_lanes):
delay = f"adelay={delay_ms}:all=1," if delay_ms > 0 else ""
# Shift before the delay: rubberband would otherwise chew through the
- # silent lead-in padding for nothing.
- filters.append(f"[{i}:a]{_pitch_filter(lane.pitch)}{delay}volume={lane.gain:.6f}[a{i}]")
+ # silent lead-in padding for nothing. EQ before the shift, because the
+ # live engine's EQ sits before its pitch stage too (between the lane
+ # gain and the pitch bus): a boost at 1 kHz on a lane moved up two
+ # semitones is heard at 1.12 kHz, and the export must agree.
+ eq = render_fx.lanes[i]
+ filters.append(f"[{i}:a]{eq}{_pitch_filter(lane.pitch)}{delay}volume={lane.gain:.6f}[a{i}]")
if click_lane is not None:
- filters.append(f"[{stem_count}:a]volume={click_lane.gain:.6f}[a{stem_count}]")
+ # The mono click to stereo at unity, as the app plays it (#833).
+ filters.append(
+ f"[{stem_count}:a]volume={click_lane.gain:.6f},{CLICK_TO_STEREO}[a{stem_count}]"
+ )
n = stem_count + (1 if click_lane is not None else 0)
- 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]"
+ # Master EQ on the sum (click included, as live: the click joins upstream
+ # of the master slot), then the limiter last.
+ master = render_fx.master
+ out_label = "[mix]"
codec = MIXDOWN_CODECS[ext]
# Resample to the user's chosen export rate (default 44.1 kHz = the stem rate,
# so a no-op unless changed). Applies to every audio container -- some hardware
# samplers reject anything but a specific rate.
rate = ["-ar", str(get_export_sample_rate())]
- cmd += [
- "-filter_complex",
- ";".join(filters),
- "-map",
- out_label,
- *post_seek,
- *codec,
- *rate,
- ]
+
+ def build_cmd(trim_db: float = 0.0, _alt: bool = False) -> list[str]:
+ # trim_db > 0 only on a mastered lossy export the encoder pushed over
+ # the ceiling (_render_master_verified).
+ end_chain = f"{master}{limiter}{trim_filter(trim_db)}[mix]"
+ if n > 1:
+ labels = "".join(f"[a{i}]" for i in range(n))
+ mix = f"{labels}amix=inputs={n}:normalize=0,{end_chain}"
+ else:
+ mix = f"[a0]{end_chain}"
+ return [
+ *cmd,
+ "-filter_complex",
+ ";".join([*filters, mix]),
+ "-map",
+ out_label,
+ *post_seek,
+ *codec,
+ *rate,
+ ]
context = f"mixdown job={job_id} ext={ext} stems={stems}"
- headers = {
- "Content-Disposition": (
- f'attachment; filename="{_mixdown_download_name(job_id, ext, start is not None)}"'
+ if plan is not None and master_settings is not None and lossy:
+ rendered, out = await _render_master_verified(
+ build_cmd, f".{ext}", context, master_settings.ceiling, cache_path, cache_key
)
- }
+ headers.update(master_headers(plan, out))
+ return FileResponse(rendered, media_type=media_type, headers=headers)
+ cmd = build_cmd()
if ext in _SEEKABLE_OUTPUT_EXTS:
# Rendered to a file so the muxer can seek back and finish its header
# (#458). It lands in the cache, so nothing is deleted afterwards --
@@ -1276,6 +2013,76 @@ async def get_mixdown(
)
+@router.get("/jobs/{job_id}/mixdown/loudness")
+async def get_mixdown_loudness(
+ request: Request,
+ job_id: str,
+ stems: str = Query(..., description="Comma-separated lane names to sum"),
+ gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
+ pitches: str = Query(default="", max_length=128, description="As for mixdown"),
+ fx: str = Query(default="", max_length=FX_PARAM_MAX_CHARS, description="As for mixdown"),
+ master: str = Query(..., max_length=MASTER_PARAM_MAX_CHARS, description="As for mixdown"),
+ lossy: bool = Query(default=False, description="Plan for a lossy export's ceiling"),
+ lanes: str | None = Query(
+ default=None, max_length=LANES_PARAM_MAX_CHARS, description=LANES_QUERY_DESCRIPTION
+ ),
+) -> dict:
+ """The measured master of this mix (the same plan the export renders):
+ loudness before, after the compressor and after the master, the gain and
+ the compressor threshold. The studio's Master readout and its live
+ preview read it. The whole song is always measured, so there is no
+ start/end; a mix measured once is answered from the cache.
+
+ The studio asks again every time the mix settles and drops the older
+ request, so a measurement whose client has gone away is stopped (its
+ ffmpeg killed) rather than left to delay the one that is wanted."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ names, parsed_gains = _parse_lane_gains(stems, gains)
+ parsed_pitches = _parse_pitches(pitches, names)
+ fx_req = parse_fx_query(fx)
+ settings = parse_master_query(master)
+ if settings is None:
+ raise HTTPException(status_code=422, detail="master: required")
+ lane_set = parse_lanes_param(lanes)
+ if any(parsed_pitches) and not await asyncio.to_thread(_rubberband_available):
+ raise HTTPException(
+ status_code=422,
+ detail="this ffmpeg build cannot transpose (no rubberband filter)",
+ )
+ render_lanes, _unpitched = _expand_render_lanes(
+ job_id, names, parsed_gains, parsed_pitches, lanes=lane_set
+ )
+ paths = [lane.path for lane in render_lanes]
+ render_fx = await asyncio.to_thread(
+ build_render_fx, [lane.name for lane in render_lanes], paths, fx_req
+ )
+ task = asyncio.create_task(
+ master_plan(
+ job_id,
+ paths,
+ [
+ _lane_chain(render_fx.lanes[i], lane.pitch, lane.gain)
+ for i, lane in enumerate(render_lanes)
+ ],
+ render_fx.master,
+ settings,
+ lossy,
+ _take_versions(job_id, names),
+ )
+ )
+ while not task.done():
+ await asyncio.wait({task}, timeout=_DISCONNECT_POLL_SEC)
+ if not task.done() and await request.is_disconnected():
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+ # Nobody is listening; the status is for the log.
+ raise HTTPException(status_code=499, detail="client went away")
+ plan, _rate = task.result()
+ return master_plan_view(plan)
+
+
def _title_slug(title: str | None) -> str:
"""Sanitize a song title into a filename-safe slug (matches the frontend).
@@ -1307,6 +2114,22 @@ async def get_video_mixdown(
click_mult: float = Query(default=1.0, description="Click rate: 0.5, 1 or 2"),
click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"),
click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"),
+ click_sound: str = Query(
+ default=DEFAULT_CLICK_SOUND, max_length=32, description=CLICK_SOUND_QUERY_DESCRIPTION
+ ),
+ fx: str = Query(
+ default="",
+ max_length=FX_PARAM_MAX_CHARS,
+ description="The studio EQ, as for mixdown. Empty means no EQ.",
+ ),
+ master: str = Query(
+ default="",
+ max_length=MASTER_PARAM_MAX_CHARS,
+ description="The master, as for mixdown. Empty means no master.",
+ ),
+ lanes: str | None = Query(
+ default=None, max_length=LANES_PARAM_MAX_CHARS, description=LANES_QUERY_DESCRIPTION
+ ),
) -> 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
@@ -1329,6 +2152,8 @@ async def get_video_mixdown(
names, parsed_gains = _parse_lane_gains(stems, gains)
parsed_pitches = _parse_pitches(pitches, names)
+ fx_req = parse_fx_query(fx)
+ master_settings = parse_master_query(master)
# Same refusal as the audio export: a video whose audio came back in the
# original key would be the same complaint reported again (#592).
if any(parsed_pitches) and not await asyncio.to_thread(_rubberband_available):
@@ -1338,17 +2163,47 @@ async def get_video_mixdown(
)
# Validates job_id (404), job done (404), and path traversal (404) per stem,
# and rebuilds a transposed "original" lane from its parts.
- render_lanes, _unpitched = _expand_render_lanes(job_id, names, parsed_gains, parsed_pitches)
+ render_lanes, _unpitched = _expand_render_lanes(
+ job_id, names, parsed_gains, parsed_pitches, lanes=parse_lanes_param(lanes)
+ )
paths = [lane.path for lane in render_lanes]
# Click is one more audio input. It must be appended before the video input
# so the audio indices the filter graph references stay contiguous from 0.
click_lane = await asyncio.to_thread(
- _click_lane, job_id, click, click_mult, click_accent, click_gain
+ _click_lane,
+ job_id,
+ click,
+ click_mult,
+ click_accent,
+ click_gain,
+ sound=check_click_sound(click_sound),
)
if click_lane is not None:
paths = [*paths, click_lane[0]]
- render_lanes = [*render_lanes, _RenderLane(click_lane[0], click_lane[1], 0)]
+ render_lanes = [*render_lanes, _RenderLane(click_lane[0], click_lane[1], 0, "")]
+ render_fx = await asyncio.to_thread(
+ build_render_fx, [lane.name for lane in render_lanes], paths, fx_req
+ )
+ # The master, measured on the music lanes (the click is the last input and
+ # is left out of the measurement), replaces the lossy export limiter.
+ limiter = EXPORT_LIMITER_LOSSY
+ plan: MasterPlan | None = None
+ if master_settings is not None:
+ music = len(render_lanes) - (1 if click_lane is not None else 0)
+ plan, rate_in = await master_plan(
+ job_id,
+ paths[:music],
+ [
+ _lane_chain(render_fx.lanes[i], lane.pitch, lane.gain)
+ for i, lane in enumerate(render_lanes[:music])
+ ],
+ render_fx.master,
+ master_settings,
+ True,
+ _take_versions(job_id, names),
+ )
+ limiter = plan_chain(plan, rate_in)
cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
for p in paths:
@@ -1357,43 +2212,67 @@ async def get_video_mixdown(
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.
+ # EQ first in each lane, as in get_mixdown; the click lane has none, and
+ # goes to stereo at unity as in get_mixdown (#833).
+ click_idx = len(render_lanes) - 1 if click_lane is not None else -1
filters = [
- f"[{i}:a]{_pitch_filter(lane.pitch)}volume={lane.gain:.6f}[a{i}]"
+ f"[{i}:a]{render_fx.lanes[i]}{_pitch_filter(lane.pitch)}volume={lane.gain:.6f}"
+ f"{',' + CLICK_TO_STEREO if i == click_idx else ''}[a{i}]"
for i, lane in enumerate(render_lanes)
]
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",
- ]
+ # Same limiter as an MP3 or OGG mixdown: this is the same mix, encoded
+ # lossy (AAC), so it must not clip where the audio export no longer does.
+ # The master EQ goes before it.
+ master = render_fx.master
+ out_label = "[mix]"
+
+ def build_cmd(trim_db: float = 0.0, alt: bool = False) -> list[str]:
+ end_chain = f"{master}{limiter}{trim_filter(trim_db)}[mix]"
+ if n > 1:
+ labels = "".join(f"[a{i}]" for i in range(n))
+ mix = f"{labels}amix=inputs={n}:normalize=0,{end_chain}"
+ else:
+ mix = f"[a0]{end_chain}"
+ return [
+ *cmd,
+ "-filter_complex",
+ ";".join([*filters, mix]),
+ "-map",
+ out_label,
+ "-map",
+ f"{video_idx}:v",
+ "-c:v",
+ "copy",
+ *(AAC_ENCODE_ARGS_ALT if alt else AAC_ENCODE_ARGS),
+ "-shortest",
+ "-movflags",
+ "frag_keyframe+empty_moov",
+ "-f",
+ "mp4",
+ ]
filename = f"{_safe_title(job.title)}_video.mp4"
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
+ context = f"video-mux job={job_id} stems={stems}"
+ if plan is not None and master_settings is not None:
+ # A mastered video is rendered to a file first, so its decoded true
+ # peak can be checked against the ceiling (_render_master_verified),
+ # then sent and removed.
+ rendered, out = await _render_master_verified(
+ build_cmd, ".mp4", context, master_settings.ceiling, None, None, True
+ )
+ headers.update(master_headers(plan, out))
+ return FileResponse(
+ rendered,
+ media_type="video/mp4",
+ headers=headers,
+ background=BackgroundTask(_unlink_later, rendered),
+ )
return StreamingResponse(
- _stream_ffmpeg(cmd, context=f"video-mux job={job_id} stems={stems}"),
+ _stream_ffmpeg([*build_cmd(), "pipe:1"], context=context),
media_type="video/mp4",
- headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ headers=headers,
)
@@ -1407,17 +2286,24 @@ def _arcname(prefix: str, name: str, ext: str) -> str:
def _build_stems_zip(
sources: list[tuple[str, Path]], fmt: str, dest: Path, prefix: str = ""
) -> None:
- """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3,
- FLAC, and OGG 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":
+ """Blocking: write the stems into a ZIP. A file already in `fmt` is stored
+ as-is (a WAV in a WAV zip, and an MP3-stored stem in an MP3 zip, which is
+ not encoded a second time); anything else is transcoded per stem via
+ ffmpeg. ZIP_STORED throughout - audio doesn't meaningfully compress, and
+ STORED keeps the build fast. Runs in a thread."""
+ if all(p.suffix.lower() == f".{fmt}" for _name, p in sources):
with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
- zf.write(p, arcname=_arcname(prefix, name, "wav"))
+ zf.write(p, arcname=_arcname(prefix, name, fmt))
return
encode = _ENCODE_ARGS[fmt]
- with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
+ # Beside the zip, so the startup sweep finds what a killed build left.
+ work = tempfile.TemporaryDirectory(prefix=".zipwork-", dir=dest.parent)
+ with work as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
+ if p.suffix.lower() == f".{fmt}":
+ zf.write(p, arcname=_arcname(prefix, name, fmt))
+ continue
out = os.path.join(td, f"{name}.{fmt}")
cmd = [
ffmpeg_executable(),
@@ -1444,28 +2330,170 @@ def _build_stems_zip(
zf.write(out, arcname=_arcname(prefix, name, fmt))
+class _ClickStem(NamedTuple):
+ """What the stems zip's click entry is rendered from (#805): the same
+ choices the click in a mix export is, so the two click identically."""
+
+ grid: dict
+ gain: float
+ multiplier: float
+ accent_mode: int
+ groups: list[int] | None
+ sound: str
+
+
+def _render_click_stem(click: _ClickStem, reference: Path, fmt: str, workdir: Path) -> Path | None:
+ """Blocking: the isolated click track for the stems zip, in `fmt`.
+
+ Sample-aligned with the stems by construction: rendered from the beat grid
+ in song time from sample 0 (the stems' own origin), at the reference
+ stem's sample rate and channel count, and exactly its decoded length in
+ frames. An MP3-stored stem decodes to the WAV's frame count (#783), which
+ is what soundfile reports for it here.
+
+ Gain: what the click contributes to a mix export, so the zip's files sum
+ to that mix at unity stem gains: the click's export level (`click_gain`),
+ and the mono click on both channels at unity (CLICK_TO_STEREO), as the
+ mix graph does and as the app plays it (#833).
+
+ The end of the chain is the export limiter for the format, as for every
+ exported mix: bit-transparent below its -1 dBFS ceiling for WAV and FLAC
+ (a click at the default 0.6 never reaches it), and the true-peak one for
+ MP3 and OGG. apad/atrim pin the length after it: the lossy limiter's
+ oversampling round trip may otherwise move the end by a few samples.
+
+ Returns None when the grid has no beats."""
+ beats = click.grid.get("beats") or []
+ if not beats:
+ return None
+ info = sf.info(str(reference))
+ frames = int(info.frames)
+ rate = int(info.samplerate)
+ channels = int(info.channels)
+ raw = render_click_wav(
+ workdir / "click_mono.wav",
+ beats,
+ click.grid.get("bars") or [],
+ frames / rate,
+ sample_rate=rate,
+ multiplier=click.multiplier,
+ accent_mode=click.accent_mode,
+ groups=click.groups,
+ sound=click.sound,
+ frames=frames,
+ )
+ if raw is None:
+ return None
+ # To the stems' layout by the same conversion the mix export's graph
+ # applies to the click (the mono click on both channels at unity, #833),
+ # so the click in the zip is exactly the click in the mix.
+ upmix = f"{CLICK_TO_STEREO}," if channels == 2 else ""
+ chain = (
+ f"volume={max(0.0, min(4.0, click.gain)):.6f},{upmix}"
+ f"{export_limiter(lossy=fmt in _LOSSY_MIXDOWN_EXTS)},aresample={rate},"
+ f"apad=whole_len={frames},atrim=end_sample={frames}"
+ )
+ out = workdir / f"{CLICK_STEM_NAME}.{fmt}"
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(raw),
+ "-af",
+ chain,
+ *_ENCODE_ARGS[fmt],
+ "-f",
+ fmt,
+ str(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 the click stem: {tail}")
+ return out
+
+
+def _build_stems_zip_with_click(
+ sources: list[tuple[str, Path]],
+ fmt: str,
+ dest: Path,
+ prefix: str,
+ click: _ClickStem | None,
+) -> None:
+ """Blocking: _build_stems_zip, plus the click entry when one is asked for.
+ The first source is the length reference: every stem and take shares the
+ song's frame count."""
+ if click is None:
+ _build_stems_zip(sources, fmt, dest, prefix)
+ return
+ with tempfile.TemporaryDirectory(prefix=".zipwork-", dir=dest.parent) as td:
+ path = _render_click_stem(click, sources[0][1], fmt, Path(td))
+ extra = [(CLICK_STEM_NAME, path)] if path is not None else []
+ _build_stems_zip([*sources, *extra], fmt, dest, prefix)
+
+
@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"),
+ click: bool = Query(default=False, description="Add the click track as its own file"),
+ click_mult: float = Query(default=1.0, description="Click rate: 0.5, 1 or 2"),
+ click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"),
+ click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"),
+ click_groups: str = Query(
+ default="",
+ max_length=64,
+ description='Beat grouping for an explicit meter, e.g. "3+2+2". Blank uses the default.',
+ ),
+ click_sound: str = Query(
+ default=DEFAULT_CLICK_SOUND, max_length=32, description=CLICK_SOUND_QUERY_DESCRIPTION
+ ),
) -> 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."""
+ every available stem is included.
+
+ `click` adds the click track as one more file, `_click.`
+ (#805), rendered from the song's beat grid (the user's edits when there
+ are any) with the same rate, accents, grouping, level and sound as the
+ click in a mix export. It starts at the song's first sample like every
+ stem, with the stems' length, rate and channels, so it lines up with them
+ in any player. A count-in is not part of it: the stems in a zip are not
+ delayed (an MP3 stem is stored without being re-encoded), so a count-in
+ could only sit before the first sample, which no stem has. The count-in
+ stays a mix-export option. Without `click` the zip is exactly as before."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
if fmt not in ("wav", "mp3", "flac", "ogg"):
raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', 'flac', or 'ogg'")
+ check_click_sound(click_sound)
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
+ # An encoder this ffmpeg lacks (libvorbis is optional, #824): refused
+ # here rather than as a 500 after the first stem fails to encode.
+ unavailable = await asyncio.to_thread(unavailable_detail, fmt)
+ if unavailable:
+ raise HTTPException(status_code=422, detail=unavailable)
# Resolve the requested subset (whitelisted) or fall back to all stems.
+ # Takes go in only when asked for by id: "all stems" means the separation.
all_names = (*STEM_NAMES, *EXTRA_STEM_NAMES)
+ take_names: list[str] = []
if stems:
requested = {s for s in stems.split(",") if s}
+ take_names = sorted(n for n in requested if _is_recorded_lane(n))
+ requested -= set(take_names)
if not requested <= set(all_names):
raise HTTPException(status_code=422, detail="unknown stem requested")
if "vocals" in requested and _VOCAL_DECOMPOSITION_NAMES & requested:
@@ -1484,21 +2512,60 @@ async def get_all_stems_zip(
sources: list[tuple[str, Path]] = []
for name in wanted:
- p = (stems_dir / f"{name}.wav").resolve()
+ p = stem_path(stems_dir, name).resolve()
if p.is_file() and p.is_relative_to(jobs_root):
sources.append((name, p))
+ labels = {t.get("id"): t.get("label") for t in job.takes}
+ labels.update({t.get("id"): t.get("name") for t in job.tracks})
+ for lane in take_names:
+ path = _take_lane_path(job_id, lane)
+ # Named for the label (take) or name (track) the user gave it, with
+ # the id to keep two called "Take 1" apart. _title_slug keeps it to
+ # [A-Za-z0-9_].
+ label = _title_slug(labels.get(lane))
+ sources.append((f"{label}_{lane}" if label else lane, path))
if not sources:
raise HTTPException(status_code=404, detail="no stems found")
- fd, tmp = tempfile.mkstemp(prefix="stemdeck_zip_", suffix=".zip")
+ click_stem: _ClickStem | None = None
+ if click:
+ grid = await asyncio.to_thread(_read_beat_grid, job_id)
+ if grid is not None:
+ click_stem = _ClickStem(
+ grid,
+ click_gain,
+ click_mult if click_mult in (0.5, 1.0, 2.0) else 1.0,
+ click_accent,
+ _parse_groups(click_groups, click_accent),
+ click_sound,
+ )
+
+ # In the export cache under a dot name, like a render: the cache prune
+ # leaves it alone, and the startup sweep removes it if the process dies.
+ _MIXDOWN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix=".stemdeck_zip_", suffix=".zip", dir=_MIXDOWN_CACHE_DIR)
os.close(fd)
tmp_path = Path(tmp)
+ temp = _ZipTemp(tmp_path)
+
+ def build() -> None:
+ try:
+ _build_stems_zip_with_click(sources, fmt, tmp_path, _title_slug(job.title), click_stem)
+ except Exception:
+ # Logged here: after a cancel nobody awaits the thread.
+ logger.exception("failed to build stems zip for job %s", job_id)
+ raise
+ finally:
+ temp.end("build")
+
try:
- await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path, _title_slug(job.title))
+ await asyncio.to_thread(build)
except Exception:
- tmp_path.unlink(missing_ok=True)
- logger.exception("failed to build stems zip for job %s", job_id)
+ temp.end("request")
raise HTTPException(status_code=500, detail="failed to build archive") from None
+ except BaseException:
+ temp.end("request")
+ raise
filename = f"{_safe_title(job.title)}_stems.zip"
return FileResponse(
diff --git a/app/api/suggest.py b/app/api/suggest.py
new file mode 100644
index 00000000..35c37a12
--- /dev/null
+++ b/app/api/suggest.py
@@ -0,0 +1,375 @@
+"""Suggest EQ and master: GET routes that measure a lane or the mix and
+propose ordinary EQ settings (and, for the master, a loudness preset).
+
+- ``GET /api/jobs/{id}/suggest/eq?lane=&target=&layout=&mode=&fx=``: one
+ lane (a stem, "original", or a recording track), measured raw from its file.
+- ``GET /api/jobs/{id}/suggest/master?stems=&gains=&pitches=&fx=&target=&mode=``:
+ the mix as the export builds it (lanes, gains, keys, lane EQ), without the
+ master EQ, measured over the whole song.
+
+Both answer with numbers and i18n keys (app/pipeline/eq_suggest.py). The
+measurement (the gated spectrum, app/pipeline/eq_analysis.py, and ebur128's
+loudness and true peak) runs once per lane or mix in one ffmpeg pass and is
+cached on disk; asking for another target or mode reuses it. A request whose
+client has gone away is stopped, like the loudness route.
+
+Validation follows the new-endpoint and security skills: the job id first,
+lane names from the allow-lists (stems, "original", take and track ids), the
+path resolved under JOBS_DIR by the same guards as the exports, every other
+parameter from a fixed set or through the strict fx= parser, all before any
+I/O; error details are generic.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import hashlib
+import json
+import logging
+import os
+import uuid
+from collections import deque
+from pathlib import Path
+
+import numpy as np
+from fastapi import APIRouter, HTTPException, Query, Request
+
+from app.api.stems import (
+ _DISCONNECT_POLL_SEC,
+ _MIXDOWN_NAMES,
+ LANES_PARAM_MAX_CHARS,
+ LANES_QUERY_DESCRIPTION,
+ _expand_render_lanes,
+ _is_recorded_lane,
+ _lane_chain,
+ _parse_lane_gains,
+ _parse_pitches,
+ _rubberband_available,
+ _take_lane_path,
+ _take_versions,
+ _validate_stem_path,
+ build_render_fx,
+ parse_fx_query,
+ parse_lanes_param,
+)
+from app.core.config import CACHE_DIR, JOB_ID_RE, TIMEOUT_FFMPEG, ffmpeg_executable
+from app.pipeline.eq_analysis import (
+ ANALYSIS_RATE,
+ Spectrum,
+ SpectrumAccumulator,
+ spectrum_from_json,
+ spectrum_to_json,
+)
+from app.pipeline.eq_fit import MAX_BANDS_LANE, MAX_BANDS_MASTER, recommend_master
+from app.pipeline.eq_suggest import make_suggestion
+from app.pipeline.eq_targets import default_target, get_target, targets_for_uses, uses_for_lane
+from app.pipeline.fx_filters import FX_PARAM_MAX_CHARS, FxRequest
+from app.pipeline.mastering import Loudness, meter_filter, parse_meter, plan_graph
+
+logger = logging.getLogger("stemdeck.suggest")
+
+router = APIRouter(tags=["suggest"])
+
+_SUGGEST_CACHE_DIR = CACHE_DIR / "suggest"
+_CACHE_MAX_FILES = 200
+_CACHE_VERSION = "1"
+# Two analyses at once at most; one per lane or mix at a time.
+_analysis_slots = asyncio.Semaphore(2)
+_analysis_locks: dict[str, asyncio.Lock] = {}
+
+# Panel kinds (static/js/eq/eqPanelKinds.js) and the fitter layout each uses.
+LANE_LAYOUTS = {"graph": "free", "drum": "free", "bass": "bass", "guitar": "guitar", "keys": "keys"}
+MODES = ("replace", "merge")
+_MERGE_LAYOUTS = ("graph", "drum")
+_READ_BYTES = 1 << 20
+_FRAME_BYTES = 8 # two float32 channels
+
+
+class AnalysisError(RuntimeError):
+ """ffmpeg failed while analysing. The message is for the log only."""
+
+
+async def _run_analysis(inputs: list[str], graph: str) -> tuple[Spectrum, Loudness]:
+ """One ffmpeg pass: ``graph`` (ending in [pre]) resampled to the analysis
+ rate, split into ebur128 (stderr) and 32-bit float stereo (stdout), the
+ latter streamed into the spectrum accumulator."""
+ rate = ANALYSIS_RATE
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-hide_banner",
+ "-nostats",
+ "-loglevel",
+ "info",
+ *inputs,
+ "-filter_complex",
+ f"{graph};[pre]aresample={rate},asplit=2[m][o];[m]{meter_filter(rate)}[mo]",
+ "-map",
+ "[mo]",
+ "-f",
+ "null",
+ "-",
+ "-map",
+ "[o]",
+ "-ac",
+ "2",
+ "-c:a",
+ "pcm_f32le",
+ "-f",
+ "f32le",
+ "pipe:1",
+ ]
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
+ )
+ tail: deque[str] = deque(maxlen=64)
+
+ async def drain() -> None:
+ assert proc.stderr is not None
+ while True:
+ line = await proc.stderr.readline()
+ if not line:
+ return
+ tail.append(line.decode("utf-8", "replace").rstrip())
+
+ acc = SpectrumAccumulator()
+
+ async def read() -> None:
+ assert proc.stdout is not None
+ carry = b""
+ while True:
+ chunk = await proc.stdout.read(_READ_BYTES)
+ if not chunk:
+ break
+ data = carry + chunk
+ usable = len(data) - len(data) % _FRAME_BYTES
+ carry = data[usable:]
+ if usable:
+ samples = np.frombuffer(data[:usable], dtype=" tuple[Spectrum, Loudness] | None:
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return spectrum_from_json(data["spectrum"]), Loudness(**data["loudness"])
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError, KeyError, TypeError):
+ logger.debug("unreadable suggest cache %s", path, exc_info=True)
+ return None
+
+
+def _write_cached(path: Path, spec: Spectrum, loud: Loudness) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ body = {
+ "spectrum": spectrum_to_json(spec),
+ "loudness": {"i": loud.i, "tp": loud.tp, "lra": loud.lra},
+ }
+ tmp.write_text(json.dumps(body, separators=(",", ":")), encoding="utf-8")
+ os.replace(tmp, path)
+ try:
+ entries = sorted(
+ (p for p in path.parent.iterdir() if p.is_file() and not p.name.startswith(".")),
+ key=lambda p: p.stat().st_mtime,
+ )
+ for old in entries[: max(0, len(entries) - _CACHE_MAX_FILES)]:
+ if old != path:
+ old.unlink(missing_ok=True)
+ except OSError:
+ logger.debug("suggest cache prune failed", exc_info=True)
+
+
+async def analysis(job_id: str, raw_key: str, inputs: list[str], graph: str):
+ """The cached measurement for ``raw_key``, measured now when absent."""
+ key = hashlib.sha1(raw_key.encode("utf-8"), usedforsecurity=False).hexdigest()
+ path = _SUGGEST_CACHE_DIR / f"{key}.json"
+ cached = await asyncio.to_thread(_read_cached, path)
+ if cached is not None:
+ return cached
+ lock = _analysis_locks.setdefault(key, asyncio.Lock())
+ try:
+ async with lock:
+ cached = await asyncio.to_thread(_read_cached, path)
+ if cached is not None:
+ return cached
+ async with _analysis_slots:
+ try:
+ spec, loud = await _run_analysis(inputs, graph)
+ except AnalysisError as exc:
+ logger.warning("suggest analysis failed [job=%s]: %s", job_id, exc)
+ raise HTTPException(status_code=500, detail="analysis failed") from None
+ await asyncio.to_thread(_write_cached, path, spec, loud)
+ return spec, loud
+ finally:
+ if not lock.locked():
+ _analysis_locks.pop(key, None)
+
+
+async def _until_done(request: Request, coro):
+ """Run ``coro``, stopping it (and its ffmpeg) when the client goes away."""
+ task = asyncio.create_task(coro)
+ while not task.done():
+ await asyncio.wait({task}, timeout=_DISCONNECT_POLL_SEC)
+ if not task.done() and await request.is_disconnected():
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+ raise HTTPException(status_code=499, detail="client went away")
+ return task.result()
+
+
+def _choices(uses: tuple[str, ...]) -> list[dict]:
+ return [{"id": t.id, "label_key": t.label_key} for t in targets_for_uses(uses)]
+
+
+def _file_fragment(path: Path) -> str:
+ st = path.stat()
+ return f"{path}@{st.st_size}:{st.st_mtime_ns}"
+
+
+@router.get("/jobs/{job_id}/suggest/eq")
+async def suggest_lane_eq(
+ request: Request,
+ job_id: str,
+ lane: str = Query(..., max_length=32, description="Mixer lane: a stem, original, or a track"),
+ target: str = Query(default="", max_length=40, description="Target id; default per lane"),
+ layout: str = Query(default="graph", max_length=16, description="The lane's EQ panel kind"),
+ mode: str = Query(default="replace", max_length=16, description="replace or merge"),
+ fx: str = Query(default="", max_length=FX_PARAM_MAX_CHARS, description="The lane's EQ now"),
+) -> dict:
+ """Measure one lane (its file, before any EQ) and suggest EQ bands that
+ move it towards a target curve, in the lane's panel's own terms."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ if layout not in LANE_LAYOUTS:
+ raise HTTPException(status_code=422, detail="unknown layout")
+ if mode not in MODES or (mode == "merge" and layout not in _MERGE_LAYOUTS):
+ raise HTTPException(status_code=422, detail="unknown mode")
+ recorded = _is_recorded_lane(lane)
+ if not recorded and lane not in _MIXDOWN_NAMES:
+ raise HTTPException(status_code=422, detail="unknown lane")
+ uses = uses_for_lane(lane, recorded)
+ if not uses:
+ raise HTTPException(status_code=422, detail="no target for this lane")
+ chosen = get_target(target) if target else default_target(lane, recorded)
+ if chosen is None or chosen.use not in uses:
+ raise HTTPException(status_code=422, detail="unknown target")
+ fx_req = parse_fx_query(fx)
+ path = _take_lane_path(job_id, lane) if recorded else _validate_stem_path(job_id, lane)
+ raw_key = "|".join([_CACHE_VERSION, "lane", job_id, lane, _file_fragment(path)])
+ spec, _loud = await _until_done(
+ request, analysis(job_id, raw_key, ["-i", str(path)], "[0:a]anull[pre]")
+ )
+ current = (fx_req.lanes.get(lane) or [None])[0]
+ answer = await asyncio.to_thread(
+ make_suggestion,
+ spec,
+ chosen,
+ LANE_LAYOUTS[layout],
+ mode,
+ current,
+ MAX_BANDS_LANE,
+ _choices(uses),
+ )
+ answer["lane"] = lane
+ return answer
+
+
+@router.get("/jobs/{job_id}/suggest/master")
+async def suggest_master(
+ request: Request,
+ job_id: str,
+ stems: str = Query(..., description="Comma-separated lane names to sum"),
+ gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
+ pitches: str = Query(default="", max_length=128, description="As for mixdown"),
+ fx: str = Query(default="", max_length=FX_PARAM_MAX_CHARS, description="As for mixdown"),
+ target: str = Query(default="", max_length=40, description="Target id; default balanced"),
+ mode: str = Query(default="replace", max_length=16, description="replace or merge"),
+ lanes: str | None = Query(
+ default=None, max_length=LANES_PARAM_MAX_CHARS, description=LANES_QUERY_DESCRIPTION
+ ),
+) -> dict:
+ """Measure the mix (as the export sums it, before the master EQ) and
+ suggest master EQ bands and a loudness preset."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ if mode not in MODES:
+ raise HTTPException(status_code=422, detail="unknown mode")
+ chosen = get_target(target) if target else get_target("mix.balanced")
+ if chosen is None or chosen.use != "mix":
+ raise HTTPException(status_code=422, detail="unknown target")
+ names, parsed_gains = _parse_lane_gains(stems, gains)
+ parsed_pitches = _parse_pitches(pitches, names)
+ fx_req = parse_fx_query(fx)
+ if any(parsed_pitches) and not await asyncio.to_thread(_rubberband_available):
+ raise HTTPException(
+ status_code=422,
+ detail="this ffmpeg build cannot transpose (no rubberband filter)",
+ )
+ render_lanes, _unpitched = _expand_render_lanes(
+ job_id, names, parsed_gains, parsed_pitches, lanes=parse_lanes_param(lanes)
+ )
+ paths = [lane.path for lane in render_lanes]
+ # The lanes' EQ is part of the mix; the master EQ is what is suggested.
+ lanes_only = FxRequest(lanes=fx_req.lanes)
+ render_fx = await asyncio.to_thread(
+ build_render_fx, [lane.name for lane in render_lanes], paths, lanes_only
+ )
+ chains = [
+ _lane_chain(render_fx.lanes[i], lane.pitch, lane.gain)
+ for i, lane in enumerate(render_lanes)
+ ]
+ graph = plan_graph(chains, "")
+ inputs: list[str] = []
+ for p in paths:
+ inputs += ["-i", str(p)]
+ raw_key = "|".join(
+ [
+ _CACHE_VERSION,
+ "mix",
+ job_id,
+ ";".join(str(p) for p in paths),
+ graph,
+ ",".join(_take_versions(job_id, names)),
+ ]
+ )
+ spec, loud = await _until_done(request, analysis(job_id, raw_key, inputs, graph))
+ current = (fx_req.master or [None])[0]
+ answer = await asyncio.to_thread(
+ make_suggestion,
+ spec,
+ chosen,
+ "master",
+ mode,
+ current,
+ MAX_BANDS_MASTER,
+ _choices(("mix",)),
+ )
+ answer["master"] = recommend_master(loud.i, loud.tp, chosen.target_lufs)
+ answer["analysis"].update({"i": loud.i, "tp": loud.tp, "lra": loud.lra})
+ return answer
diff --git a/app/api/takes.py b/app/api/takes.py
new file mode 100644
index 00000000..23b36a4e
--- /dev/null
+++ b/app/api/takes.py
@@ -0,0 +1,2011 @@
+"""Recorded takes: upload, finish, edit, delete, serve, and the video render.
+
+Contract (agreed with the recording and video frontends):
+
+ POST /api/jobs/{id}/takes create, 201 {take_id, label}
+ PUT /api/jobs/{id}/takes/{take_id}/parts/{kind}?seq=n raw bytes, kind audio|camera
+ GET /api/jobs/{id}/takes/{take_id}/parts upload state, to resync
+ POST /api/jobs/{id}/takes/{take_id}/finish sidecar -> audio.wav, job.takes
+ PATCH /api/jobs/{id}/takes/{take_id} label, user_nudge_ms, camera_offset_ms
+ DELETE /api/jobs/{id}/takes/{take_id}
+ GET /api/jobs/{id}/takes/{take_id}/audio.wav (and HEAD)
+ GET /api/jobs/{id}/takes/{take_id}/camera
+ POST /api/jobs/{id}/takes/{take_id}/render 202, background video render
+ GET /api/jobs/{id}/takes/{take_id}/render progress
+ POST /api/jobs/{id}/takes/{take_id}/render/cancel
+ GET /api/jobs/{id}/takes/{take_id}/render.mp4
+
+Placement: the sidecar's timeline.first_sample_song_sec already has the
+round-trip latency folded in (the browser knows it); the server applies the
+user's nudge, and is the only place that does, so changing it later can
+re-render from the raw capture:
+
+ placement_sec = first_sample_song_sec - user_nudge_ms / 1000
+
+Positive means late, subtract: the same sign as every latency in the capture
+layer.
+
+Render length (RenderBody.span): "take" (the default) covers the take's own
+span, from its count-in to where it stopped, clamped to the song; "song"
+covers the whole song; "range" (Streamer Mode's trim) covers range_in to
+range_out, inside the take's own span. The backing plays across the whole
+render either way; the camera shows where it filmed and the canvas is black
+elsewhere.
+
+Streamer Mode records its composed scene as the take's camera part and asks
+for canvas 16:9, 9:16 or 1:1 at size 720 or 1080 (the short side) and
+fps 24, 30 or 60, fit "fill" (the picture is already the canvas's shape),
+and optionally the click as one more lane.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import math
+import os
+import re
+import secrets
+import shutil
+import subprocess
+import threading
+import time
+import unicodedata
+import uuid
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal, NamedTuple
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import FileResponse, JSONResponse
+from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError, field_validator
+
+from app.api import stems as stems_api
+from app.core.config import (
+ EXTRA_STEM_NAMES,
+ FILMSTRIP_FRAME_HEIGHT,
+ FILMSTRIP_MAX_FRAMES,
+ JOB_ID_RE,
+ JOBS_DIR,
+ RENDER_CLICK_GAIN,
+ STEM_NAMES,
+ TAKE_AUDIO_MAX_BYTES_PER_SEC,
+ TAKE_CAMERA_MAX_BYTES_PER_SEC,
+ TAKE_ID_RE,
+ TAKE_MAX_PER_JOB,
+ TAKE_MAX_SEQ,
+ TAKE_PART_MAX_BYTES,
+ TAKE_SLACK_SEC,
+ TAKES_DIRNAME,
+ TIMEOUT_FILMSTRIP,
+ TIMEOUT_RENDER_MAX,
+ TIMEOUT_VIDEO_GPU_STALL,
+ TIMEOUT_VIDEO_RENDER,
+ TRACK_ID_RE,
+ TRACK_MAX_PER_JOB,
+ VIDEO_CPU_ENCODER,
+ VIDEO_RENDER_SEC_PER_SEC,
+ ffmpeg_executable,
+ ffprobe_executable,
+)
+from app.core.models import Job, _set
+from app.core.registry import get as registry_get
+from app.core.registry import persist as registry_persist
+from app.core.settings import get_export_sample_rate
+from app.core.stem_files import stem_path
+from app.core.takes import (
+ camera_file,
+ make_track,
+ new_track_id,
+ normalise_tracks,
+ take_dir,
+ take_entry,
+ write_tracks,
+)
+from app.pipeline import video_encoder
+from app.pipeline.click_render import CLICK_TO_STEREO
+from app.pipeline.click_sounds import DEFAULT_CLICK_SOUND
+from app.pipeline.collect import merge_stem_peaks
+from app.pipeline.runner import _pipeline_lock
+from app.pipeline.take_render import (
+ TAKE_RATE,
+ AudioLane,
+ RenderCancelled,
+ TakeRenderError,
+ aac_available,
+ assemble_raw_wav,
+ build_video_render_cmd,
+ canvas_size,
+ libx264_available,
+ render_take_audio,
+ run_video_render,
+ sniff_container,
+ wav_frames,
+)
+
+logger = logging.getLogger("stemdeck.api")
+
+router = APIRouter(tags=["takes"])
+
+_PART_KINDS = ("audio", "camera")
+_STATE_FILE = "parts.json"
+_SIDECAR_FILE = "take.json"
+_RENDER_FILE = "render.mp4"
+_LABEL_MAX = 80
+_SESSION_MAX_BYTES = 8 * 1024
+_NUDGE_MAX_MS = 2000.0
+_CAMERA_OFFSET_MAX_MS = 5000.0
+_RENDER_MAX_LANES = 32
+_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
+
+# One lock per take: parts, finish, edit and delete of the same take never
+# interleave, so the append offset and parts.json always agree. Keyed by
+# "/"; at most TAKE_MAX_PER_JOB per job, dropped on delete.
+_take_locks: dict[str, asyncio.Lock] = {}
+
+
+def _lock_for(job_id: str, take_id: str) -> asyncio.Lock:
+ key = f"{job_id}/{take_id}"
+ lock = _take_locks.get(key)
+ if lock is None:
+ lock = _take_locks[key] = asyncio.Lock()
+ return lock
+
+
+# ---------------------------------------------------------------------------
+# Validation helpers. Order, per the security skill: job id, then take id and
+# kind whitelist, then the resolved path inside JOBS_DIR, then any I/O.
+
+
+def _job_or_404(job_id: str) -> 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 or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not ready")
+ return job
+
+
+def _take_or_404(job_id: str, take_id: str) -> tuple[Job, Path]:
+ job = _job_or_404(job_id)
+ if not TAKE_ID_RE.match(take_id):
+ raise HTTPException(status_code=404, detail="take not found")
+ folder = take_dir(job_id, take_id, JOBS_DIR)
+ if folder is None or not folder.is_dir():
+ raise HTTPException(status_code=404, detail="take not found")
+ return job, folder
+
+
+def _entry(job: Job, take_id: str) -> dict[str, Any] | None:
+ return next((t for t in job.takes if t.get("id") == take_id), None)
+
+
+def _finished_or_404(job_id: str, take_id: str) -> tuple[Job, Path, dict[str, Any]]:
+ job, folder = _take_or_404(job_id, take_id)
+ entry = _entry(job, take_id)
+ if entry is None:
+ raise HTTPException(status_code=404, detail="take not finished")
+ return job, folder, entry
+
+
+def _clean_label(value: str | None) -> str | None:
+ if value is None:
+ return None
+ value = value.strip()
+ if not value or len(value) > _LABEL_MAX or _CONTROL_CHARS.search(value):
+ raise ValueError(f"label must be 1 to {_LABEL_MAX} printable characters")
+ return value
+
+
+def _reject_constant(token: str) -> float:
+ raise ValueError(f"{token} is not a number")
+
+
+async def _parse_body(request: Request, model: type[BaseModel], *, empty_ok: bool = False):
+ """Parse a JSON body into `model`, after the ids have been checked.
+
+ Not a FastAPI body parameter, for two reasons. Order: a body parameter is
+ validated before the handler runs, so a bad body on an unknown job was a
+ 422 instead of a 404. And NaN: json accepts the NaN and Infinity tokens,
+ and FastAPI's 422 then echoes the offending input back, which it cannot
+ serialise, so a NaN in a sidecar came back as a 500. Refused here, at
+ parse time, and the 422 detail never repeats the input."""
+ raw = await request.body()
+ if not raw.strip():
+ if empty_ok:
+ return model()
+ raise HTTPException(status_code=422, detail="request body required")
+ try:
+ data = json.loads(raw, parse_constant=_reject_constant)
+ except ValueError:
+ raise HTTPException(
+ status_code=422, detail="body must be JSON with finite numbers"
+ ) from None
+ try:
+ return model.model_validate(data)
+ except ValidationError as exc:
+ detail = "; ".join(
+ f"{'.'.join(str(p) for p in err['loc']) or 'body'}: {err['msg']}"
+ for err in exc.errors(include_input=False, include_url=False)[:5]
+ )
+ raise HTTPException(status_code=422, detail=detail[:500]) from None
+
+
+# ---------------------------------------------------------------------------
+# Small file helpers, all blocking.
+
+
+def _write_json_atomic(path: Path, data: dict) -> None:
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read %s", path, exc_info=True)
+ raise HTTPException(status_code=500, detail="take is unreadable") from None
+ if not isinstance(data, dict):
+ raise HTTPException(status_code=500, detail="take is unreadable")
+ return data
+
+
+def _new_state(label: str) -> dict[str, Any]:
+ return {
+ "label": label,
+ "created_at": time.time(),
+ "finished": False,
+ **{kind: {"next_seq": 0, "bytes": 0} for kind in _PART_KINDS},
+ }
+
+
+def _reference_stem(job_id: str) -> Path | None:
+ """A stem whose length every take is cut to. Any of them: the pipeline
+ writes all of them from one Demucs pass, so they share a frame count."""
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return None
+ for name in (*STEM_NAMES, *EXTRA_STEM_NAMES, "original"):
+ # WAV or MP3 (#783): an MP3 stem decodes to the WAV's exact frame
+ # count, which stem_storage verified before keeping it.
+ path = stem_path(stems_dir, name)
+ if path.is_file():
+ return path
+ return None
+
+
+def _song_frames(job_id: str) -> int:
+ ref = _reference_stem(job_id)
+ if ref is None:
+ raise HTTPException(status_code=404, detail="stems not found")
+ return wav_frames(ref)
+
+
+def _size_cap(job: Job, kind: str) -> int:
+ seconds = (job.duration_sec or 3600.0) + TAKE_SLACK_SEC
+ per_sec = TAKE_AUDIO_MAX_BYTES_PER_SEC if kind == "audio" else TAKE_CAMERA_MAX_BYTES_PER_SEC
+ return int(seconds * per_sec)
+
+
+def _placement_sec(sidecar: dict[str, Any]) -> float:
+ timeline = sidecar.get("timeline") or {}
+ latency = sidecar.get("latency") or {}
+ return float(timeline.get("first_sample_song_sec") or 0.0) - (
+ float(latency.get("user_nudge_ms") or 0.0) / 1000.0
+ )
+
+
+def _scene_start_sec(sidecar: dict[str, Any], video: dict[str, Any], offset_ms: float) -> float:
+ """Song time of a composed scene's first frame (Streamer Mode, fit "fill").
+
+ A camera films the player, who plays to what was heard one round trip
+ before the microphone took it: the take's audio is placed with the round
+ trip and the nudge taken off (first_sample_song_sec, _placement_sec), and
+ the camera shares that placement. A composed scene is not filmed: each
+ frame draws the playhead heard at the moment it is drawn, so its frame 0
+ is at the song time heard then, the round trip added back and no nudge.
+ Measured on exports (fix pass G): with the round trip taken off, the
+ picture ran a round trip early (-113 ms at 150 ms, +20 ms at 0)."""
+ timeline = sidecar.get("timeline") or {}
+ latency = sidecar.get("latency") or {}
+ first = float(timeline.get("first_sample_song_sec") or 0.0)
+ round_trip = float(latency.get("round_trip_ms") or 0.0) / 1000.0
+ return (
+ first + round_trip + float(video.get("firstFrameAtMs") or 0.0) / 1000.0 - offset_ms / 1000.0
+ )
+
+
+def _refresh_peaks(job_id: str, take_id: str, folder: Path, *, remove: bool = False) -> None:
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_dir() or not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return
+ if remove:
+ merge_stem_peaks(stems_dir, [], remove=[take_id])
+ else:
+ merge_stem_peaks(stems_dir, [take_id], sources={take_id: folder / "audio.wav"})
+
+
+def _commit(job: Job, takes: list[dict[str, Any]], tracks: list[dict[str, Any]]) -> None:
+ """Write tracks and takes together: tracks.json first (the file wins on
+ load), then the job, through _set so the SSE stream sees one change, then
+ the registry. Synchronous and free of awaits on purpose: every read and
+ write of job.tracks happens inside one event-loop step, so two requests
+ can never interleave a read-modify-write of the track list."""
+ tracks = normalise_tracks(tracks, takes)
+ if tracks != job.tracks:
+ write_tracks(JOBS_DIR / job.id, tracks)
+ _set(job, takes=takes, tracks=tracks)
+ registry_persist(JOBS_DIR)
+
+
+def _replace_entry(
+ job: Job, entry: dict[str, Any] | None, take_id: str, *, activate: bool = False
+) -> None:
+ """Swap one take's record in job.takes (None removes it) and persist.
+
+ With `activate`, the take becomes its track's active take, creating the
+ track when it is missing (a take opened before tracks existed). Removing
+ the active take falls back to the track's newest remaining one."""
+ takes = [t for t in job.takes if t.get("id") != take_id]
+ tracks = [dict(t) for t in job.tracks]
+ if entry is not None:
+ takes.append(entry)
+ takes.sort(key=lambda t: t.get("created_at") or 0.0)
+ if activate:
+ track = next((t for t in tracks if t["id"] == entry.get("track_id")), None)
+ if track is None:
+ track = make_track(entry["track_id"], entry["label"], len(tracks))
+ tracks.append(track)
+ track["active_take_id"] = take_id
+ _commit(job, takes, tracks)
+
+
+def _track_or_404(job: Job, track_id: str) -> dict[str, Any]:
+ if not TRACK_ID_RE.match(track_id):
+ raise HTTPException(status_code=404, detail="track not found")
+ track = next((t for t in job.tracks if t.get("id") == track_id), None)
+ if track is None:
+ raise HTTPException(status_code=404, detail="track not found")
+ return track
+
+
+# ---------------------------------------------------------------------------
+# Create
+
+
+class CreateTakeBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ track_id: str | None = Field(default=None, max_length=16)
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+@router.post("/{job_id}/takes", status_code=201)
+async def create_take(job_id: str, request: Request) -> dict[str, str]:
+ """Open a new take folder on a recording track: {"track_id", "label"?}."""
+ job = _job_or_404(job_id)
+ body = await _parse_body(request, CreateTakeBody, empty_ok=True)
+ if body.track_id is None:
+ raise HTTPException(status_code=422, detail="track_id required")
+ if not TRACK_ID_RE.match(body.track_id) or not any(
+ t.get("id") == body.track_id for t in job.tracks
+ ):
+ raise HTTPException(status_code=422, detail="unknown track")
+ track_id = body.track_id
+ on_track = sum(1 for t in job.takes if t.get("track_id") == track_id)
+
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+
+ def make() -> tuple[str, str]:
+ root.mkdir(parents=True, exist_ok=True)
+ existing = [p for p in root.iterdir() if p.is_dir() and TAKE_ID_RE.match(p.name)]
+ if len(existing) >= TAKE_MAX_PER_JOB:
+ raise HTTPException(status_code=409, detail="too many takes for this song")
+ label = body.label or f"Take {on_track + 1}"
+ for _ in range(8):
+ take_id = "t" + secrets.token_hex(4)
+ folder = root / take_id
+ try:
+ folder.mkdir()
+ except FileExistsError:
+ continue
+ _write_json_atomic(folder / _STATE_FILE, {**_new_state(label), "track_id": track_id})
+ return take_id, label
+ raise HTTPException(status_code=500, detail="could not create take")
+
+ take_id, label = await asyncio.to_thread(make)
+ logger.info("[%s] take %s created on track %s", job.id, take_id, track_id)
+ return {"take_id": take_id, "label": label, "track_id": track_id}
+
+
+# ---------------------------------------------------------------------------
+# Parts
+
+
+async def _read_body_capped(request: Request) -> bytes:
+ """The raw body, refused past TAKE_PART_MAX_BYTES while it streams in.
+ The middleware already refuses a declared length over 4 MB; this covers a
+ body longer than the length it declared."""
+ chunks: list[bytes] = []
+ total = 0
+ async for chunk in request.stream():
+ total += len(chunk)
+ if total > TAKE_PART_MAX_BYTES:
+ raise HTTPException(status_code=413, detail="part too large")
+ chunks.append(chunk)
+ return b"".join(chunks)
+
+
+@router.put("/{job_id}/takes/{take_id}/parts/{kind}")
+async def put_part(
+ job_id: str,
+ take_id: str,
+ kind: str,
+ request: Request,
+ seq: int = Query(..., ge=0, le=TAKE_MAX_SEQ),
+) -> dict[str, Any]:
+ """Append one part. seq must be exactly the next one; repeating the last
+ one is a no-op, so a retry after a lost response is safe."""
+ job, folder = _take_or_404(job_id, take_id)
+ if kind not in _PART_KINDS:
+ raise HTTPException(status_code=404, detail="unknown part kind")
+ data = await _read_body_capped(request)
+ if not data:
+ raise HTTPException(status_code=422, detail="empty part")
+ if kind == "audio" and len(data) % 4:
+ raise HTTPException(status_code=422, detail="audio part must be whole float32 samples")
+
+ async with _lock_for(job_id, take_id):
+ state_path = folder / _STATE_FILE
+ state = await asyncio.to_thread(_read_json, state_path)
+ if state.get("finished"):
+ raise HTTPException(status_code=409, detail="take already finished")
+ slot = state.setdefault(kind, {"next_seq": 0, "bytes": 0})
+ next_seq = int(slot.get("next_seq", 0))
+ received = int(slot.get("bytes", 0))
+ if next_seq > 0 and seq == next_seq - 1:
+ return {
+ "take_id": take_id,
+ "kind": kind,
+ "seq": seq,
+ "bytes": received,
+ "duplicate": True,
+ }
+ if seq != next_seq:
+ raise HTTPException(status_code=409, detail="unexpected part sequence")
+ if received + len(data) > _size_cap(job, kind):
+ raise HTTPException(status_code=413, detail="take too large for this song")
+
+ def append() -> None:
+ part = folder / f"{kind}.part"
+ with part.open("ab") as fh:
+ # Cut back to what parts.json says arrived: a crash between
+ # the write and the state update must not leave the part
+ # that will be retried in the file twice.
+ fh.truncate(received)
+ fh.seek(received)
+ fh.write(data)
+ slot["next_seq"] = seq + 1
+ slot["bytes"] = received + len(data)
+ _write_json_atomic(state_path, state)
+
+ await asyncio.to_thread(append)
+ return {
+ "take_id": take_id,
+ "kind": kind,
+ "seq": seq,
+ "bytes": received + len(data),
+ "duplicate": False,
+ }
+
+
+@router.get("/{job_id}/takes/{take_id}/parts")
+async def get_parts(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder = _take_or_404(job_id, take_id)
+ state = await asyncio.to_thread(_read_json, folder / _STATE_FILE)
+ return {
+ **{
+ kind: {
+ "next_seq": int((state.get(kind) or {}).get("next_seq", 0)),
+ "bytes": int((state.get(kind) or {}).get("bytes", 0)),
+ }
+ for kind in _PART_KINDS
+ },
+ "finished": bool(state.get("finished")),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Finish: the sidecar
+
+
+def _finite(lo: float, hi: float, default: float | None = None) -> Any:
+ return Field(default=default, ge=lo, le=hi, allow_inf_nan=False)
+
+
+class TimelineModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ first_sample_song_sec: float = Field(ge=-600, le=86_400, allow_inf_nan=False)
+ start_song_sec: float | None = _finite(-86_400, 86_400)
+ end_song_sec: float | None = _finite(-86_400, 86_400)
+ tempo: float = _finite(0.01, 100, 1.0)
+ count_in_sec: float | None = _finite(0, 600)
+ ctx_time_at_frame0: float | None = _finite(-1e12, 1e12)
+ perf_ms_at_frame0: float | None = _finite(-1e15, 1e15)
+
+
+class LatencyModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ round_trip_ms: float | None = _finite(-10_000, 10_000)
+ source: Literal["calibrated", "estimated"] | None = None
+ output_latency_ms: float | None = _finite(-10_000, 10_000)
+ base_latency_ms: float | None = _finite(-10_000, 10_000)
+ input_latency_ms_reported: float | None = _finite(-10_000, 10_000)
+ pipeline_latency_ms: float | None = _finite(-10_000, 10_000)
+ user_nudge_ms: float = _finite(-_NUDGE_MAX_MS, _NUDGE_MAX_MS, 0.0)
+ drift_ppm: float | None = _finite(-10_000, 10_000)
+
+
+class CaptureModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ rate: int = Field(ge=8000, le=192_000)
+ channels: Literal[1, 2]
+ device_label: str | None = Field(default=None, max_length=200)
+ dropped_quanta: int | None = Field(default=None, ge=0, le=10**12)
+
+
+class VideoModel(BaseModel):
+ """The camera block (06-video-recording 5.5). Absent fields take their
+ defaults: the browser omits what it did not report."""
+
+ model_config = ConfigDict(extra="ignore")
+ file: str | None = Field(default=None, max_length=100) # ignored, set by the server
+ mime: str | None = Field(default=None, max_length=100)
+ w: int | None = Field(default=None, ge=1, le=4096)
+ h: int | None = Field(default=None, ge=1, le=4096)
+ fps_nominal: float | None = _finite(1, 240)
+ deviceLabel: str | None = Field(default=None, max_length=200) # noqa: N815 -- agreed wire name
+ cameraOffsetMs: float = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS, 0.0) # noqa: N815
+ mirrorPreview: StrictBool | None = None # noqa: N815
+ startedAtAudioCtx: float | None = _finite(-1e12, 1e12) # noqa: N815
+ startedAtPerf: float | None = _finite(-1e15, 1e15) # noqa: N815
+ firstFrameAtMs: float = _finite(-600_000, 600_000, 0.0) # noqa: N815
+ # How firstFrameAtMs was taken: the first frame's capture time, or the
+ # recorder's start event when the engine reports no capture times.
+ firstFrameClock: Literal["capture", "recorder-start"] | None = None # noqa: N815
+ parts: int | None = Field(default=None, ge=0, le=TAKE_MAX_SEQ + 1)
+
+
+class FinishBody(BaseModel):
+ """take.json, version 1 (05-audio-recording 5.6). id and created_at are
+ the server's and ignored when sent."""
+
+ model_config = ConfigDict(extra="ignore")
+ v: Literal[1]
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ timeline: TimelineModel
+ latency: LatencyModel = Field(default_factory=LatencyModel)
+ capture: CaptureModel
+ video: VideoModel | None = None
+ session: dict[str, Any] | None = None
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+ @field_validator("session")
+ @classmethod
+ def _bounded_session(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
+ # Agent 7's block: stored, not interpreted here, so only its size and
+ # JSON-ness are checked (no NaN, nothing json cannot write back).
+ if v is None:
+ return v
+ try:
+ encoded = json.dumps(v, allow_nan=False)
+ except (TypeError, ValueError):
+ raise ValueError("session must be plain JSON") from None
+ if len(encoded.encode("utf-8")) > _SESSION_MAX_BYTES:
+ raise ValueError("session block too large")
+ return v
+
+
+@router.post("/{job_id}/takes/{take_id}/finish")
+async def finish_take(job_id: str, take_id: str, request: Request) -> dict[str, Any]:
+ """Close the take: build audio.raw.wav from the audio parts, name the
+ camera file, render audio.wav aligned and padded to the stems, merge its
+ peaks, write take.json, and add the lane to the job. Synchronous: a
+ 5-minute take renders in a second or two."""
+ job, folder = _take_or_404(job_id, take_id)
+ body: FinishBody = await _parse_body(request, FinishBody)
+ if body.timeline.tempo != 1.0:
+ raise HTTPException(status_code=422, detail="takes recorded at a tempo other than 1.0")
+ frames = await asyncio.to_thread(_song_frames, job_id)
+ song_sec = frames / TAKE_RATE
+ if body.timeline.first_sample_song_sec > song_sec + 600:
+ raise HTTPException(status_code=422, detail="take starts after the song")
+
+ async with _lock_for(job_id, take_id):
+ state_path = folder / _STATE_FILE
+ state = await asyncio.to_thread(_read_json, state_path)
+ if state.get("finished"):
+ raise HTTPException(status_code=409, detail="take already finished")
+ channels = body.capture.channels
+ rate = body.capture.rate
+ audio_part = folder / "audio.part"
+ raw_wav = folder / "audio.raw.wav"
+ camera_part = folder / "camera.part"
+
+ def prepare() -> str | None:
+ """Validate every input before changing any file, so a refused
+ finish can be corrected and sent again."""
+ if audio_part.is_file():
+ size = audio_part.stat().st_size
+ if size == 0:
+ raise HTTPException(status_code=422, detail="take has no audio")
+ if size % (4 * channels):
+ raise HTTPException(
+ status_code=422, detail="audio size does not match the channel count"
+ )
+ elif not raw_wav.is_file():
+ raise HTTPException(status_code=422, detail="take has no audio")
+ camera_ext = None
+ # Without the video block the camera file is not used: the
+ # frontend sends the block only when every camera part arrived,
+ # so a missing one means a file with a hole or an unknown start,
+ # which would render out of sync. It is discarded in build().
+ # The block also says how many parts were sent; fewer on disk
+ # means the end of the recording is missing, and the take keeps
+ # its sound without the video rather than a short one.
+ sent = body.video.parts if body.video is not None else None
+ received = int((state.get("camera") or {}).get("next_seq", 0))
+ if sent is not None and sent != received:
+ logger.warning(
+ "[%s] take %s: %d camera parts sent, %d received; video dropped",
+ job_id,
+ take_id,
+ sent,
+ received,
+ )
+ return None
+ if body.video is not None and camera_part.is_file() and camera_part.stat().st_size > 0:
+ camera_ext = sniff_container(camera_part)
+ if camera_ext is None:
+ raise HTTPException(status_code=422, detail="camera file is not WebM or MP4")
+ return camera_ext
+
+ camera_ext = await asyncio.to_thread(prepare)
+
+ sidecar = body.model_dump(exclude_none=True)
+ sidecar["id"] = take_id
+ sidecar["created_at"] = float(state.get("created_at") or time.time())
+ sidecar["label"] = body.label or str(state.get("label") or take_id)
+ # The track it was opened on; a take opened before tracks existed, or
+ # whose track was deleted meanwhile, gets a new track of its own.
+ track_id = str(state.get("track_id") or "")
+ if not any(t.get("id") == track_id for t in job.tracks):
+ track_id = new_track_id({t["id"] for t in job.tracks})
+ sidecar["track_id"] = track_id
+ if camera_ext is not None:
+ sidecar["video"]["file"] = f"camera.{camera_ext}"
+ else:
+ sidecar.pop("video", None)
+ placement = _placement_sec(sidecar)
+
+ def build() -> None:
+ if audio_part.is_file():
+ assemble_raw_wav(audio_part, raw_wav, rate, channels)
+ audio_part.unlink()
+ if camera_ext is not None:
+ os.replace(camera_part, folder / f"camera.{camera_ext}")
+ else:
+ camera_part.unlink(missing_ok=True)
+ render_take_audio(raw_wav, folder / "audio.wav", placement, frames, rate, channels)
+ _write_json_atomic(folder / _SIDECAR_FILE, sidecar)
+ _refresh_peaks(job_id, take_id, folder)
+ state["finished"] = True
+ _write_json_atomic(state_path, state)
+
+ try:
+ await asyncio.to_thread(build)
+ except TakeRenderError:
+ logger.warning("[%s] take %s render failed", job_id, take_id, exc_info=True)
+ raise HTTPException(status_code=500, detail="take render failed") from None
+
+ entry = take_entry(job_id, sidecar, camera_ext is not None)
+ _replace_entry(job, entry, take_id, activate=True)
+ logger.info("[%s] take %s finished (placement %.3f s)", job_id, take_id, placement)
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# Edit, delete
+
+
+class PatchTakeBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ user_nudge_ms: float | None = _finite(-_NUDGE_MAX_MS, _NUDGE_MAX_MS)
+ camera_offset_ms: float | None = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS)
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+@router.patch("/{job_id}/takes/{take_id}")
+async def patch_take(job_id: str, take_id: str, request: Request) -> dict[str, Any]:
+ """Rename, nudge (re-renders audio.wav from the raw capture), or set the
+ camera offset. A changed nudge or offset drops an earlier video render:
+ it no longer matches what the user hears."""
+ job, folder = _take_or_404(job_id, take_id)
+ body: PatchTakeBody = await _parse_body(request, PatchTakeBody)
+ if body.label is None and body.user_nudge_ms is None and body.camera_offset_ms is None:
+ raise HTTPException(status_code=422, detail="nothing to change")
+ if _entry(job, take_id) is None:
+ raise HTTPException(status_code=409, detail="take not finished")
+ if _render_busy(job_id, take_id):
+ raise HTTPException(status_code=409, detail="video render running")
+
+ async with _lock_for(job_id, take_id):
+ sidecar = await asyncio.to_thread(_read_json, folder / _SIDECAR_FILE)
+ has_camera = await asyncio.to_thread(camera_file, folder)
+ if body.camera_offset_ms is not None and has_camera is None:
+ raise HTTPException(status_code=422, detail="take has no camera")
+ latency = sidecar.setdefault("latency", {})
+ old_nudge = float(latency.get("user_nudge_ms") or 0.0)
+ video = sidecar.get("video") or {}
+ old_offset = float(video.get("cameraOffsetMs") or 0.0)
+
+ if body.label is not None:
+ sidecar["label"] = body.label
+ nudged = body.user_nudge_ms is not None and body.user_nudge_ms != old_nudge
+ if body.user_nudge_ms is not None:
+ latency["user_nudge_ms"] = body.user_nudge_ms
+ offset_moved = body.camera_offset_ms is not None and body.camera_offset_ms != old_offset
+ if body.camera_offset_ms is not None:
+ sidecar.setdefault("video", {})["cameraOffsetMs"] = body.camera_offset_ms
+
+ def apply() -> None:
+ if nudged:
+ frames = _song_frames(job_id)
+ capture = sidecar.get("capture") or {}
+ render_take_audio(
+ folder / "audio.raw.wav",
+ folder / "audio.wav",
+ _placement_sec(sidecar),
+ frames,
+ int(capture.get("rate") or TAKE_RATE),
+ int(capture.get("channels") or 2),
+ )
+ _refresh_peaks(job_id, take_id, folder)
+ if nudged or offset_moved:
+ (folder / _RENDER_FILE).unlink(missing_ok=True)
+ _write_json_atomic(folder / _SIDECAR_FILE, sidecar)
+
+ try:
+ await asyncio.to_thread(apply)
+ except TakeRenderError:
+ logger.warning("[%s] take %s re-render failed", job_id, take_id, exc_info=True)
+ raise HTTPException(status_code=500, detail="take render failed") from None
+ if nudged or offset_moved:
+ _renders.pop((job_id, take_id), None)
+ entry = take_entry(job_id, sidecar, has_camera is not None)
+ _replace_entry(job, entry, take_id)
+ return entry
+
+
+@router.delete("/{job_id}/takes/{take_id}")
+async def delete_take(job_id: str, take_id: str) -> dict[str, str]:
+ """Remove a take, finished or not: folder, lane and waveform."""
+ job, folder = _take_or_404(job_id, take_id)
+ async with _lock_for(job_id, take_id):
+ # A render of this take stops first (its ffmpeg would hold the files
+ # open on Windows) and is waited for.
+ run = _renders.pop((job_id, take_id), None)
+ if run is not None:
+ await asyncio.to_thread(_stop_and_wait, run)
+ removed = await asyncio.to_thread(_remove_take_folder, job_id, take_id, folder)
+ if _entry(job, take_id) is not None:
+ _replace_entry(job, None, take_id)
+ _take_locks.pop(f"{job_id}/{take_id}", None)
+ _forget_filmstrip_locks(folder)
+ if not removed:
+ raise HTTPException(status_code=500, detail="take files could not be deleted")
+ return {"take_id": take_id, "status": "deleted"}
+
+
+def _remove_take_folder(job_id: str, take_id: str, folder: Path) -> bool:
+ """Blocking. Retried briefly: on Windows a render ffmpeg that was just
+ killed can hold camera.* open for a moment after it exits."""
+ for attempt in range(10):
+ try:
+ shutil.rmtree(folder)
+ break
+ except FileNotFoundError:
+ break
+ except OSError:
+ if attempt == 9:
+ logger.warning("could not remove take %s", folder, exc_info=True)
+ time.sleep(0.2)
+ _refresh_peaks(job_id, take_id, folder, remove=True)
+ return not folder.exists()
+
+
+# ---------------------------------------------------------------------------
+# Tracks
+
+
+class TrackInput(BaseModel):
+ """Which input a track records from. Only stored for the UI: capture runs
+ in the browser and the take's sidecar says what was actually captured."""
+
+ model_config = ConfigDict(extra="forbid")
+ device_id: str | None = Field(default=None, max_length=256)
+ device_label: str | None = Field(default=None, max_length=200)
+ channels: list[int] = Field(min_length=1, max_length=2)
+
+ @field_validator("channels")
+ @classmethod
+ def _channels(cls, v: list[int]) -> list[int]:
+ if any(isinstance(c, bool) or not 0 <= c <= 63 for c in v):
+ raise ValueError("channel index must be 0..63")
+ if len(set(v)) != len(v):
+ raise ValueError("a stereo pair needs two different channels")
+ return v
+
+ @field_validator("device_id", "device_label")
+ @classmethod
+ def _printable(cls, v: str | None) -> str | None:
+ if v is not None and _CONTROL_CHARS.search(v):
+ raise ValueError("must be printable")
+ return v
+
+
+class CreateTrackBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ name: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ input: TrackInput | None = None
+ # A video track films its takes with the camera as well as recording
+ # sound. Only stored for the UI, like the input.
+ kind: Literal["audio", "video"] | None = None
+
+ @field_validator("name")
+ @classmethod
+ def _name(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+class PatchTrackBody(CreateTrackBody):
+ active_take_id: str | None = Field(default=None, max_length=16)
+ order: int | None = Field(default=None, ge=0, le=TRACK_MAX_PER_JOB)
+
+
+def _track_view(job: Job, track_id: str) -> dict[str, Any]:
+ return next(t for t in job.tracks if t["id"] == track_id)
+
+
+@router.post("/{job_id}/tracks", status_code=201)
+async def create_track(job_id: str, request: Request) -> dict[str, Any]:
+ """Add a recording track: {} or {"name"?, "input"?, "kind"?}."""
+ job = _job_or_404(job_id)
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+ body = await _parse_body(request, CreateTrackBody, empty_ok=True)
+ # No await from here to the commit: see _commit.
+ if len(job.tracks) >= TRACK_MAX_PER_JOB:
+ raise HTTPException(status_code=409, detail="too many tracks for this song")
+ used = {t["id"] for t in job.tracks}
+ track_id = new_track_id(used)
+ names = {t.get("name") for t in job.tracks}
+ n = len(job.tracks) + 1
+ while f"Track {n}" in names:
+ n += 1
+ track = make_track(
+ track_id,
+ body.name or f"Track {n}",
+ len(job.tracks),
+ body.input.model_dump() if body.input else None,
+ body.kind or "audio",
+ )
+ _commit(job, list(job.takes), [*job.tracks, track])
+ logger.info("[%s] track %s created", job_id, track_id)
+ return _track_view(job, track_id)
+
+
+@router.patch("/{job_id}/tracks/{track_id}")
+async def patch_track(job_id: str, track_id: str, request: Request) -> dict[str, Any]:
+ """Rename, set the input (null clears it), switch the active take, or
+ move the track to another position."""
+ job = _job_or_404(job_id)
+ _track_or_404(job, track_id)
+ body = await _parse_body(request, PatchTrackBody)
+ fields = body.model_fields_set
+ if not fields:
+ raise HTTPException(status_code=422, detail="nothing to change")
+ # Re-read after the await, then no await until the commit.
+ track = dict(_track_or_404(job, track_id))
+ if "name" in fields:
+ if body.name is None:
+ raise HTTPException(status_code=422, detail="name cannot be empty")
+ track["name"] = body.name
+ if "input" in fields:
+ track["input"] = body.input.model_dump() if body.input else None
+ if "kind" in fields:
+ track["kind"] = body.kind or "audio"
+ if "active_take_id" in fields:
+ take = next(
+ (
+ t
+ for t in job.takes
+ if t.get("id") == body.active_take_id and t.get("track_id") == track_id
+ ),
+ None,
+ )
+ if take is None:
+ raise HTTPException(status_code=422, detail="not a finished take of this track")
+ track["active_take_id"] = take["id"]
+ others = [dict(t) for t in job.tracks if t["id"] != track_id]
+ position = track["order"]
+ if "order" in fields and body.order is not None:
+ position = min(body.order, len(others))
+ others.insert(position, track)
+ for i, t in enumerate(others):
+ t["order"] = i
+ _commit(job, list(job.takes), others)
+ return _track_view(job, track_id)
+
+
+@router.delete("/{job_id}/tracks/{track_id}")
+async def delete_track(job_id: str, track_id: str) -> dict[str, Any]:
+ """Delete a track and every take on it, finished or not, stopping any
+ video render of them first."""
+ job = _job_or_404(job_id)
+ _track_or_404(job, track_id)
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+
+ def owned() -> list[str]:
+ ids = {t["id"] for t in job.takes if t.get("track_id") == track_id}
+ if root.is_dir():
+ for folder in root.iterdir():
+ if not folder.is_dir() or not TAKE_ID_RE.match(folder.name):
+ continue
+ state = folder / _STATE_FILE
+ try:
+ if json.loads(state.read_text(encoding="utf-8")).get("track_id") == track_id:
+ ids.add(folder.name)
+ except (OSError, ValueError, AttributeError):
+ continue
+ return sorted(ids)
+
+ take_ids = await asyncio.to_thread(owned)
+ # The track goes first, so a take finishing meanwhile cannot make it
+ # active again; its takes follow.
+ _commit(
+ job,
+ [t for t in job.takes if t.get("id") not in take_ids],
+ [t for t in job.tracks if t["id"] != track_id],
+ )
+ failed = False
+ for take_id in take_ids:
+ run = _renders.pop((job_id, take_id), None)
+ if run is not None:
+ _stop_run(run)
+ folder = take_dir(job_id, take_id, JOBS_DIR)
+ if folder is None:
+ continue
+ async with _lock_for(job_id, take_id):
+ if not await asyncio.to_thread(_remove_take_folder, job_id, take_id, folder):
+ failed = True
+ _take_locks.pop(f"{job_id}/{take_id}", None)
+ _forget_filmstrip_locks(folder)
+ if any(t.get("id") in take_ids for t in job.takes):
+ _commit(job, [t for t in job.takes if t.get("id") not in take_ids], list(job.tracks))
+ logger.info("[%s] track %s deleted with %d takes", job_id, track_id, len(take_ids))
+ if failed:
+ raise HTTPException(status_code=500, detail="take files could not be deleted")
+ return {"track_id": track_id, "status": "deleted", "takes_deleted": take_ids}
+
+
+# ---------------------------------------------------------------------------
+# Serve
+
+
+def _download_name(job: Job, label: str, ext: str) -> str:
+ parts = [p for p in (stems_api._title_slug(job.title), stems_api._title_slug(label)) if p]
+ return f"{'_'.join(parts) or 'take'}.{ext}"
+
+
+@router.api_route(
+ "/{job_id}/takes/{take_id}/audio.wav", methods=["GET", "HEAD"], response_model=None
+)
+async def get_take_audio(job_id: str, take_id: str) -> FileResponse:
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ path = (folder / "audio.wav").resolve()
+ if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take not found")
+ return FileResponse(
+ path, media_type="audio/wav", filename=_download_name(job, entry["label"], "wav")
+ )
+
+
+@router.get("/{job_id}/takes/{take_id}/camera", response_model=None)
+async def get_take_camera(job_id: str, take_id: str) -> FileResponse:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ path = camera_file(folder)
+ if path is None or not path.resolve().is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take has no camera")
+ media = "video/webm" if path.suffix == ".webm" else "video/mp4"
+ return FileResponse(path.resolve(), media_type=media)
+
+
+# ---------------------------------------------------------------------------
+# Filmstrip: the frames a video track draws along its lane
+
+
+_FILMSTRIP_IMAGE = "filmstrip.jpg"
+_FILMSTRIP_META = "filmstrip.json"
+# Keyed by the take's folder; dropped with the take or its job.
+_filmstrip_locks: dict[str, threading.Lock] = {}
+_filmstrip_locks_guard = threading.Lock()
+
+
+def _forget_filmstrip_locks(under: Path) -> None:
+ """Drop the filmstrip locks of every take folder in `under` (a take's
+ folder or a job's). One still held, by a build in flight, stays."""
+ with _filmstrip_locks_guard:
+ for key, lock in list(_filmstrip_locks.items()):
+ if Path(key).is_relative_to(under) and not lock.locked():
+ del _filmstrip_locks[key]
+
+
+def forget_job_locks(job_id: str) -> None:
+ """A deleted job's take and filmstrip locks go with it; held ones stay.
+ Called from a worker thread, so the take locks are read from a snapshot
+ (as cancel_job_renders reads _renders). A request already holding a
+ reference to a lock dropped here still uses it; the job is gone, so a
+ second lock for the same take cannot corrupt anything."""
+ for key, lock in list(_take_locks.items()):
+ if key.startswith(f"{job_id}/") and not lock.locked():
+ _take_locks.pop(key, None)
+ _forget_filmstrip_locks((JOBS_DIR / job_id).resolve())
+
+
+def _probe_video(path: Path) -> tuple[float, int, int]:
+ """(duration in seconds, width, height) of the first video stream."""
+ proc = subprocess.run(
+ [
+ ffprobe_executable(),
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=width,height:format=duration",
+ "-of",
+ "json",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(proc.stderr.strip()[-400:] or "ffprobe failed")
+ data = json.loads(proc.stdout or "{}")
+ stream = (data.get("streams") or [{}])[0]
+ try:
+ duration = float((data.get("format") or {}).get("duration") or 0.0)
+ except ValueError:
+ duration = 0.0
+ if duration <= 0:
+ # A WebM written as MediaRecorder streams it has no duration in its
+ # header: decode it and read how far the decoder got.
+ duration = _decoded_duration(path)
+ return duration, int(stream.get("width") or 0), int(stream.get("height") or 0)
+
+
+_DECODE_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+(?:\.\d+)?)")
+
+
+def _decoded_duration(path: Path) -> float:
+ """How long the first video stream runs: the end of its last packet.
+
+ Packet timestamps are read first because the decoder's "time=" is not the
+ same on every ffmpeg: 5.1 and 7.1 print the end of the last frame, 6.1
+ (Ubuntu 24.04) its start, one frame short, which cost a 5 s take its fifth
+ filmstrip frame. The decoder is only asked when no packet has a time."""
+ end = _last_packet_end(path)
+ if end > 0:
+ return end
+ proc = subprocess.run(
+ [ffmpeg_executable(), "-nostdin", "-i", str(path), "-map", "0:v:0", "-f", "null", "-"],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ times = _DECODE_TIME_RE.findall(proc.stderr or "")
+ if not times:
+ return 0.0
+ h, m, sec = times[-1]
+ return int(h) * 3600 + int(m) * 60 + float(sec)
+
+
+def _last_packet_end(path: Path) -> float:
+ """The latest pts + duration over the first video stream's packets, in
+ seconds, or 0.0 when ffprobe fails or no packet carries both."""
+ try:
+ proc = subprocess.run(
+ [
+ ffprobe_executable(),
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "packet=pts_time,duration_time",
+ "-of",
+ "csv=p=0",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ except (subprocess.SubprocessError, OSError):
+ return 0.0
+ if proc.returncode != 0:
+ return 0.0
+ end = 0.0
+ for line in (proc.stdout or "").splitlines():
+ parts = line.strip().split(",")
+ if len(parts) < 2:
+ continue
+ try:
+ value = float(parts[0]) + float(parts[1])
+ except ValueError:
+ continue
+ if math.isfinite(value):
+ end = max(end, value)
+ # WebM keeps whole milliseconds, so 15 fps frames last 0.066 s and a 5 s
+ # take ends at 4.999: rounded to 10 ms, it ends at 5.0 again.
+ return round(end, 2)
+
+
+def build_filmstrip(camera: Path, folder: Path) -> dict[str, Any]:
+ """The take's filmstrip, built once and kept beside the camera file
+ until the camera file changes: {interval, count, frame_width,
+ frame_height, duration}. One JPEG row of `count` frames, one every
+ `interval` seconds from the first frame."""
+ image = folder / _FILMSTRIP_IMAGE
+ meta_path = folder / _FILMSTRIP_META
+ with _filmstrip_locks_guard:
+ lock = _filmstrip_locks.setdefault(str(folder), threading.Lock())
+ with lock:
+ cam_mtime = camera.stat().st_mtime
+ if image.is_file() and meta_path.is_file() and image.stat().st_mtime >= cam_mtime:
+ try:
+ return json.loads(meta_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("unreadable %s, building it again", meta_path)
+ duration, width, height = _probe_video(camera)
+ if duration <= 0 or width <= 0 or height <= 0:
+ raise RuntimeError("the camera file has no readable video")
+ frame_h = FILMSTRIP_FRAME_HEIGHT
+ frame_w = max(2, round(width * frame_h / height / 2) * 2)
+ interval = max(1.0, duration / FILMSTRIP_MAX_FRAMES)
+ # Whole intervals only: ffmpeg gives no frame for a last part-interval,
+ # and the tile filter would pad its place in black.
+ count = max(1, min(FILMSTRIP_MAX_FRAMES, math.floor(duration / interval)))
+ tmp = folder / f".{_FILMSTRIP_IMAGE}.{uuid.uuid4().hex}.jpg"
+ try:
+ proc = subprocess.run(
+ [
+ ffmpeg_executable(),
+ "-v",
+ "error",
+ "-y",
+ "-i",
+ str(camera),
+ "-vf",
+ f"fps=1/{interval:.4f},scale={frame_w}:{frame_h},tile={count}x1",
+ "-frames:v",
+ "1",
+ "-q:v",
+ "5",
+ str(tmp),
+ ],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ if proc.returncode != 0 or not tmp.is_file():
+ raise RuntimeError(proc.stderr.strip()[-400:] or "ffmpeg failed")
+ os.replace(tmp, image)
+ finally:
+ tmp.unlink(missing_ok=True)
+ meta = {
+ "interval": round(interval, 4),
+ "count": count,
+ "frame_width": frame_w,
+ "frame_height": frame_h,
+ "duration": round(duration, 4),
+ }
+ meta_path.write_text(json.dumps(meta), encoding="utf-8")
+ return meta
+
+
+@router.get("/{job_id}/takes/{take_id}/filmstrip")
+async def get_take_filmstrip(job_id: str, take_id: str) -> dict[str, Any]:
+ """How to draw the take's frames along its lane: the filmstrip's layout
+ and where it is (filmstrip.jpg). Built on first request."""
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ camera = camera_file(folder)
+ if camera is None or not camera.resolve().is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take has no camera")
+ try:
+ meta = await asyncio.to_thread(build_filmstrip, camera.resolve(), folder)
+ except subprocess.TimeoutExpired as err:
+ logger.warning("[%s] filmstrip for %s timed out", job_id, take_id)
+ raise HTTPException(status_code=504, detail="the filmstrip took too long") from err
+ except (OSError, RuntimeError, ValueError):
+ logger.exception("[%s] filmstrip for %s failed", job_id, take_id)
+ raise HTTPException(status_code=500, detail="could not read the camera file") from None
+ return {**meta, "url": f"/api/jobs/{job_id}/takes/{take_id}/filmstrip.jpg"}
+
+
+@router.get("/{job_id}/takes/{take_id}/filmstrip.jpg", response_model=None)
+async def get_take_filmstrip_image(job_id: str, take_id: str) -> FileResponse:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ path = (folder / _FILMSTRIP_IMAGE).resolve()
+ if not path.is_relative_to(JOBS_DIR.resolve()) or not path.is_file():
+ raise HTTPException(status_code=404, detail="no filmstrip yet")
+ return FileResponse(path, media_type="image/jpeg", headers={"Cache-Control": "no-cache"})
+
+
+# ---------------------------------------------------------------------------
+# Video render
+
+
+@dataclass
+class _GpuPlan:
+ """What a GPU attempt needs: the command for (trim_db, alt_aac, encoder,
+ variant, device), and the geometry the encoder is probed and checked at.
+ `device` is h264_vaapi's render node, "" for every other encoder."""
+
+ build: Any # Callable[[float, bool, str, int, str], list[str]]
+ width: int
+ height: int
+ fps: int
+
+
+@dataclass
+class _RenderRun:
+ status: str = "queued" # queued | running | done | error
+ progress: float = 0.0
+ error: str | None = None
+ cancel: threading.Event = field(default_factory=threading.Event)
+ proc: subprocess.Popen | None = None
+ # The encoder that made (or is making) the file, and why a GPU attempt
+ # was given up for libx264 ("exit", "stall", "timeout", "invalid-"),
+ # else None. `gpu` is None for a render that never tries the GPU.
+ encoder: str | None = None
+ variant: int = 0
+ device: str = "" # h264_vaapi's render node (video_encoder.Choice)
+ fallback: str | None = None
+ gpu: _GpuPlan | None = None
+
+
+_renders: dict[tuple[str, str], _RenderRun] = {}
+_render_tasks: set[asyncio.Task] = set()
+
+
+def _render_busy(job_id: str, take_id: str) -> bool:
+ run = _renders.get((job_id, take_id))
+ return run is not None and run.status in ("queued", "running")
+
+
+def _stop_run(run: _RenderRun) -> None:
+ run.cancel.set()
+ proc = run.proc
+ if proc is not None and proc.poll() is None:
+ proc.kill()
+
+
+def _stop_and_wait(run: _RenderRun, timeout: float = 10.0) -> None:
+ """_stop_run, then wait (blocking) until its ffmpeg has exited."""
+ _stop_run(run)
+ proc = run.proc
+ if proc is not None:
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ logger.warning("video render did not stop within %.0f s", timeout)
+
+
+def cancel_all_renders() -> None:
+ """Stop every render (the server is shutting down): no ffmpeg is left
+ behind holding a take's files."""
+ for key, run in list(_renders.items()):
+ _stop_run(run)
+ _renders.pop(key, None)
+
+
+def cancel_job_renders(job_id: str) -> None:
+ """Stop every render of a job, for the job's deletion: its folder is
+ about to go, and on Windows a running ffmpeg would hold files open."""
+ for (jid, tid), run in list(_renders.items()):
+ if jid == job_id:
+ _stop_run(run)
+ _renders.pop((jid, tid), None)
+
+
+def _render_view(job_id: str, take_id: str, folder: Path) -> dict[str, Any]:
+ run = _renders.get((job_id, take_id))
+ url = f"/api/jobs/{job_id}/takes/{take_id}/render.mp4"
+ # What made the file: known while this process remembers the run (the
+ # Streamer's "Encoded with" note reads it right after the export).
+ made = {
+ "encoder": run.encoder if run is not None else None,
+ "fallback": bool(run is not None and run.fallback),
+ "fallback_reason": run.fallback if run is not None else None,
+ }
+ if run is not None and run.status != "done":
+ return {
+ "status": run.status,
+ "progress": run.progress,
+ "error": run.error,
+ "url": None,
+ **made,
+ }
+ if (folder / _RENDER_FILE).is_file():
+ return {"status": "done", "progress": 1.0, "error": None, "url": url, **made}
+ return {"status": "none", "progress": 0.0, "error": None, "url": None, **made}
+
+
+class RenderBody(BaseModel):
+ """Single-tile subset of the layout descriptor (06-video-recording 5.3)."""
+
+ model_config = ConfigDict(extra="forbid")
+ stems: list[str] = Field(default_factory=list, max_length=_RENDER_MAX_LANES)
+ gains: list[float] = Field(default_factory=list, max_length=_RENDER_MAX_LANES)
+ pitches: list[int] | None = Field(default=None, max_length=_RENDER_MAX_LANES)
+ take_gain: float = Field(default=1.0, ge=0, le=4, allow_inf_nan=False)
+ camera_offset_ms: float | None = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS)
+ canvas: Literal["16:9", "9:16", "1:1"] = "16:9"
+ # "fill": the picture is already the canvas's shape (Streamer's scene).
+ fit: Literal["cover", "contain", "fill"] = "cover"
+ mirror: StrictBool = False
+ # "take": from the take's count-in to where it stopped; "song": all of it;
+ # "range": range_in to range_out (Streamer's trim), inside the take's span.
+ span: Literal["take", "song", "range"] = "take"
+ range_in: float | None = _finite(0, stems_api.MAX_TRIM_SECONDS)
+ range_out: float | None = _finite(0, stems_api.MAX_TRIM_SECONDS)
+ # The output: the canvas's short side and the frame rate.
+ size: Literal["720", "1080"] = "1080"
+ fps: Literal[24, 30, 60] = 30
+ # The click as one more lane (the export's default click: the song's grid,
+ # every beat, accents by the detected bars).
+ click: StrictBool = False
+ # Its sound (#805): a preset id from static/js/clickSounds.js, checked
+ # against that whitelist in the handler (422 for any other).
+ click_sound: str = Field(default=DEFAULT_CLICK_SOUND, max_length=32)
+ # Streamer Mode's "I play": the stem the musician played, left out of the
+ # backing exactly as it was left out of what they heard. None: the whole song.
+ remove_stem: Literal["vocals", "drums", "bass", "guitar", "piano", "other"] | None = None
+ # The mode's own lanes, the export's lanes= value: every base stem shown as
+ # a lane, muted or not, so Original is rebuilt from every other stem as
+ # playback builds it. None: Original is the job's own file, as before.
+ lanes: list[str] | None = Field(default=None, max_length=6)
+ # The studio EQ, the export's fx= value (app/pipeline/fx_filters.py). The
+ # take gets its track's EQ; "" means no EQ.
+ fx: str = Field(default="", max_length=stems_api.FX_PARAM_MAX_CHARS)
+ # The master, the export's master= value (app/pipeline/mastering.py);
+ # "" means no master.
+ master: str = Field(default="", max_length=stems_api.MASTER_PARAM_MAX_CHARS)
+
+ @field_validator("stems")
+ @classmethod
+ def _names(cls, v: list[str]) -> list[str]:
+ for name in v:
+ if len(name) > 32:
+ raise ValueError("unknown stem requested")
+ return v
+
+
+class _RenderAudio(NamedTuple):
+ lanes: list[AudioLane]
+ take_fx: str
+ master_fx: str
+ names: list[str] # the backing lanes' mixer names (for the master's cache key)
+
+
+def _render_lanes(
+ job_id: str,
+ take_id: str,
+ body: RenderBody,
+ take_audio: Path,
+ track_id: str | None = None,
+) -> _RenderAudio:
+ """Validate and resolve the backing lanes with the export's own rules,
+ with each lane's EQ, the take's (its track's EQ) and the master's."""
+ fx_req = stems_api.parse_fx_query(body.fx)
+ lanes = _backing_lanes(job_id, take_id, body)
+ # The take is one more input, named by its track so it gets that EQ.
+ names = [name for _, name in lanes] + [track_id or ""]
+ paths = [lane.path for lane, _ in lanes] + [take_audio]
+ render_fx = stems_api.build_render_fx(names, paths, fx_req)
+ out = [lane._replace(fx_filter=render_fx.lanes[i]) for i, (lane, _) in enumerate(lanes)]
+ return _RenderAudio(out, render_fx.lanes[-1], render_fx.master, names[:-1])
+
+
+def removed_stems(remove: str | None) -> frozenset[str]:
+ """The lanes and components a removed stem takes out: Voice is the
+ vocals stem and, after a lead/backing split, both of its halves."""
+ if not remove:
+ return frozenset()
+ if remove == "vocals":
+ return frozenset({"vocals", "lead_vocals", "backing_vocals"})
+ return frozenset({remove})
+
+
+def _backing_lanes(job_id: str, take_id: str, body: RenderBody) -> list[tuple[AudioLane, str]]:
+ """The backing lanes with their mixer lane names."""
+ stems_api.check_click_sound(body.click_sound)
+ if len(body.gains) != len(body.stems):
+ raise HTTPException(status_code=422, detail="stems and gains must be equal length")
+ if any(not (0 <= g <= stems_api._MIXDOWN_MAX_GAIN) for g in body.gains):
+ raise HTTPException(status_code=422, detail="gain out of range")
+ active = {t["id"]: t.get("active_take_id") for t in registry_get(job_id).tracks}
+ if take_id in body.stems or any(active.get(n) == take_id for n in body.stems):
+ raise HTTPException(status_code=422, detail="the take is already in the video")
+ if not body.stems:
+ return []
+ if len(set(body.stems)) != len(body.stems):
+ raise HTTPException(status_code=422, detail="a lane is listed twice")
+ names, gains = stems_api._parse_lane_gains(
+ ",".join(body.stems), ",".join(f"{g:.6f}" for g in body.gains)
+ )
+ pitches = stems_api._parse_pitches(
+ ",".join(str(p) for p in body.pitches) if body.pitches else "", names
+ )
+ if any(pitches) and not stems_api._rubberband_available():
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot transpose (no rubberband filter)"
+ )
+ lane_set = None if body.lanes is None else stems_api.parse_lanes_param(",".join(body.lanes))
+ lanes, _unpitched = stems_api._expand_render_lanes(
+ job_id, names, gains, pitches, removed_stems(body.remove_stem), lanes=lane_set
+ )
+ return [
+ (AudioLane(lane.path, lane.gain, stems_api._pitch_filter(lane.pitch)), lane.name)
+ for lane in lanes
+ ]
+
+
+# The shortest take-span video: a take stopped right after its count-in
+# still gets a second of picture rather than an empty file.
+_SPAN_MIN_SEC = 1.0
+
+
+def _render_span(
+ sidecar: dict[str, Any],
+ song_sec: float,
+ span: str,
+ range_in: float | None = None,
+ range_out: float | None = None,
+) -> tuple[float, float]:
+ """(start, duration) of the video in song time.
+
+ "range" is range_in to range_out cut to the take's own span (a trim wider
+ than what was recorded would only add black), at least a second long.
+
+ The take's span starts where its count-in started (start_song_sec minus
+ count_in_sec; the take's first sample when an older sidecar has neither)
+ and ends at its last sample, nudge included, both clamped to the song:
+ before song 0 there is no backing and audio.wav starts at 0."""
+ if span == "song" or song_sec <= 0:
+ return 0.0, song_sec
+ if span == "range":
+ t_start, t_len = _render_span(sidecar, song_sec, "take")
+ t_end = t_start + t_len
+ lo = max(t_start, float(range_in or 0.0))
+ hi = min(t_end, float(range_out if range_out is not None else song_sec))
+ # A range past the take's end keeps its last second rather than black.
+ lo = min(lo, max(t_start, t_end - _SPAN_MIN_SEC))
+ hi = min(song_sec, max(hi, lo + _SPAN_MIN_SEC))
+ lo = max(0.0, min(lo, hi - _SPAN_MIN_SEC))
+ return lo, hi - lo
+ timeline = sidecar.get("timeline") or {}
+ placement = _placement_sec(sidecar)
+ first = float(timeline.get("first_sample_song_sec") or 0.0)
+ start_song = timeline.get("start_song_sec")
+ if isinstance(start_song, (int, float)):
+ start = float(start_song) - float(timeline.get("count_in_sec") or 0.0)
+ else:
+ start = placement
+ end_song = timeline.get("end_song_sec")
+ end = placement + (float(end_song) - first) if isinstance(end_song, (int, float)) else song_sec
+ start = min(max(0.0, start), song_sec)
+ end = min(max(start, end), song_sec)
+ if end - start < _SPAN_MIN_SEC:
+ end = min(song_sec, start + _SPAN_MIN_SEC)
+ start = max(0.0, end - _SPAN_MIN_SEC)
+ return start, end - start
+
+
+class _MasterRetry(NamedTuple):
+ """How to render a mastered video again when its AAC decoded over the
+ ceiling: the command for a (trim_db, alt_aac) pair, and the ceiling."""
+
+ build: Any # Callable[[float, bool], list[str]]
+ ceiling: float
+
+
+async def _verify_master(render, retry: _MasterRetry, dest: Path, job_id: str) -> None:
+ """Decode the rendered video's audio and render again, trimmed or with
+ the alternative AAC coder, while it is over the master's ceiling (the
+ rule of stems._render_master_verified). `render(trim_db, alt_aac)` makes
+ the file again with the render's encoder (and its CPU fallback)."""
+ trim, alt = 0.0, False
+ for attempt in range(stems_api.LOSSY_RETRIES + 1):
+ try:
+ out = await stems_api.measure_file(str(dest))
+ except stems_api.MeasureError as exc:
+ logger.warning("[%s] mastered video not measured: %s", job_id, exc)
+ return
+ if out.tp is None or out.tp <= retry.ceiling:
+ return
+ if attempt == stems_api.LOSSY_RETRIES:
+ logger.warning(
+ "[%s] mastered video still %.2f dB over its ceiling",
+ job_id,
+ out.tp - retry.ceiling,
+ )
+ return
+ if not alt:
+ alt = True
+ else:
+ trim += out.tp - retry.ceiling + stems_api.LOSSY_RETRY_MARGIN_DB
+ await render(trim, alt)
+
+
+async def _render_task(
+ run: _RenderRun,
+ job: Job,
+ cmd: list[str],
+ dest: Path,
+ duration: float,
+ retry: _MasterRetry | None = None,
+) -> None:
+ def cancelled() -> bool:
+ return run.cancel.is_set() or registry_get(job.id) is not job
+
+ def report(share: float) -> None:
+ run.progress = share
+
+ def on_proc(proc: subprocess.Popen | None) -> None:
+ run.proc = proc
+
+ gpu = run.gpu
+ try:
+ # Behind the pipeline lock like every other heavy on-demand run: an
+ # x264 encode and a Demucs pass on the same CPU slow each other down
+ # for no gain. "queued" until an import in progress has finished.
+ async with _pipeline_lock:
+ if cancelled():
+ raise RenderCancelled()
+ run.status = "running"
+ run.encoder = VIDEO_CPU_ENCODER
+ if gpu is not None:
+ # May run a 2 s test encode (cached afterwards, see
+ # video_encoder.probe); never raises. A cancel stops it (#790).
+ choice = await asyncio.to_thread(
+ video_encoder.choose, gpu.width, gpu.height, gpu.fps, cancelled
+ )
+ if cancelled():
+ raise RenderCancelled()
+ run.encoder, run.variant = choice.encoder, choice.variant
+ run.device = choice.device
+ logger.info(
+ "[%s] video render encoder %s%s%s",
+ job.id,
+ choice.encoder,
+ f" on {choice.device}" if choice.device else "",
+ f" ({choice.reason})" if choice.reason else "",
+ )
+
+ async def run_cmd(c: list[str], check: Any = None, stall: float | None = None) -> None:
+ await asyncio.to_thread(
+ run_video_render,
+ c,
+ dest,
+ duration,
+ cancelled=cancelled,
+ report=report,
+ on_proc=on_proc,
+ timeout=max(
+ TIMEOUT_VIDEO_RENDER,
+ min(TIMEOUT_RENDER_MAX, duration * VIDEO_RENDER_SEC_PER_SEC),
+ ),
+ check=check,
+ stall_timeout=stall,
+ )
+
+ async def render(trim: float = 0.0, alt: bool = False) -> None:
+ """One render at (trim, alt): on the GPU encoder while it
+ holds, else (or after it failed) the CPU command, which is
+ exactly the one this render had before the GPU path."""
+ if gpu is not None and run.encoder != VIDEO_CPU_ENCODER:
+ encoder = run.encoder
+
+ def check(path: Path) -> None:
+ video_encoder.validate_output(
+ path,
+ gpu.width,
+ gpu.height,
+ gpu.fps,
+ duration,
+ cancelled=cancelled,
+ on_proc=on_proc,
+ )
+
+ try:
+ await run_cmd(
+ gpu.build(trim, alt, encoder, run.variant, run.device),
+ check=check,
+ stall=TIMEOUT_VIDEO_GPU_STALL,
+ )
+ return
+ except TakeRenderError as exc:
+ # The temp file is already gone (run_video_render).
+ logger.warning(
+ "[%s] GPU render with %s failed (%s), rendering on the CPU: %s",
+ job.id,
+ encoder,
+ exc.code,
+ exc,
+ )
+ # Only the encoder's own faults count toward turning
+ # it off: never a full disk or a missing file (#791).
+ # h264_vaapi counts against the node it ran on, so
+ # the next render can move on to another (#1033).
+ if video_encoder.blames_encoder(encoder, exc.code, exc.stderr):
+ await asyncio.to_thread(
+ video_encoder.record_failure, encoder, exc.code, run.device
+ )
+ else:
+ logger.info("[%s] %s not held against the encoder", job.id, exc.code)
+ run.encoder, run.fallback = VIDEO_CPU_ENCODER, exc.code
+ run.progress = 0.0
+ if trim == 0.0 and not alt:
+ await run_cmd(cmd)
+ elif retry is not None:
+ await run_cmd(retry.build(trim, alt))
+
+ await render()
+ if retry is not None:
+ await _verify_master(render, retry, dest, job.id)
+ run.status = "done"
+ run.progress = 1.0
+ except RenderCancelled:
+ run.status = "none"
+ except TakeRenderError:
+ logger.warning("[%s] video render failed", job.id, exc_info=True)
+ run.status = "error"
+ run.error = "video render failed"
+ except Exception:
+ logger.exception("[%s] video render failed", job.id)
+ run.status = "error"
+ run.error = "video render failed"
+
+
+async def _prepare_render(
+ job_id: str,
+ take_id: str,
+ folder: Path,
+ body: RenderBody,
+ camera: Path,
+ audio: _RenderAudio,
+) -> tuple[list[str], float, _MasterRetry | None, list[AudioLane], float, _GpuPlan]:
+ """The render's command, length, master retry, lanes, start and GPU plan,
+ from the take's sidecar and the request (the slow part of start_render).
+ `cmd` is the CPU (libx264) command; the GPU plan builds the others."""
+ sidecar = await asyncio.to_thread(_read_json, folder / _SIDECAR_FILE)
+ video = sidecar.get("video") or {}
+ offset_ms = (
+ body.camera_offset_ms
+ if body.camera_offset_ms is not None
+ else float(video.get("cameraOffsetMs") or 0.0)
+ )
+ camera_start = (
+ _placement_sec(sidecar) + float(video.get("firstFrameAtMs") or 0.0) / 1000.0
+ ) - offset_ms / 1000.0
+ if body.fit == "fill":
+ camera_start = _scene_start_sec(sidecar, video, offset_ms)
+ frames = await asyncio.to_thread(_song_frames, job_id)
+ song_sec = frames / TAKE_RATE
+ if body.span == "range" and _range_outside_take(
+ sidecar, song_sec, body.range_in, body.range_out
+ ):
+ # Nothing recorded there: refused, never clamped to another second.
+ raise HTTPException(status_code=422, detail="the trim is outside what this take recorded")
+ start, duration = _render_span(sidecar, song_sec, body.span, body.range_in, body.range_out)
+ lanes = audio.lanes
+ if body.click:
+ click = await asyncio.to_thread(
+ stems_api._click_lane,
+ job_id,
+ True,
+ 1.0,
+ -1,
+ RENDER_CLICK_GAIN,
+ sound=body.click_sound,
+ )
+ if click is None:
+ # No beat grid: the render goes ahead without it, as the export does.
+ logger.info("[%s] take %s render: no click (no beat grid)", job_id, take_id)
+ else:
+ # The mono click to stereo at unity, as the export and the app (#833).
+ lanes = [*lanes, AudioLane(click.path, click.gain, "", f"{CLICK_TO_STEREO},")]
+ # The master, measured on the whole song with the take as one more lane
+ # (audio.wav is aligned to song 0), replaces the lossy export limiter.
+ master_settings = stems_api.parse_master_query(body.master)
+ master_chain = ""
+ retry: _MasterRetry | None = None
+ if master_settings is not None:
+ take_audio = folder / "audio.wav"
+ st = await asyncio.to_thread(take_audio.stat)
+ plan, rate_in = await stems_api.master_plan(
+ job_id,
+ [lane.path for lane in lanes] + [take_audio],
+ [f"{lane.fx_filter}{lane.pitch_filter}volume={lane.gain:.6f}" for lane in lanes]
+ + [f"{audio.take_fx}volume={body.take_gain:.6f}"],
+ audio.master_fx,
+ master_settings,
+ True,
+ [
+ *stems_api._take_versions(job_id, audio.names),
+ f"{take_id}@{st.st_size}:{st.st_mtime_ns}",
+ # A backing without a stem is another mix to measure.
+ *([f"without:{body.remove_stem}"] if body.remove_stem else []),
+ ],
+ )
+ master_chain = stems_api.plan_chain(plan, rate_in)
+
+ def make_cmd(
+ trim_db: float = 0.0,
+ alt_aac: bool = False,
+ encoder: str = VIDEO_CPU_ENCODER,
+ variant: int = 0,
+ device: str = "",
+ ) -> list[str]:
+ return build_video_render_cmd(
+ lanes,
+ folder / "audio.wav",
+ body.take_gain,
+ camera,
+ camera_start,
+ duration,
+ body.canvas,
+ body.fit,
+ body.mirror,
+ get_export_sample_rate(),
+ start_sec=start,
+ take_fx=audio.take_fx,
+ master_fx=audio.master_fx,
+ master_chain=master_chain,
+ trim_db=trim_db,
+ alt_aac=alt_aac,
+ size=body.size,
+ fps=body.fps,
+ encoder=encoder,
+ encoder_variant=variant,
+ encoder_device=device,
+ )
+
+ cmd = make_cmd()
+ if master_settings is not None:
+ retry = _MasterRetry(make_cmd, master_settings.ceiling)
+ width, height = canvas_size(body.canvas, body.size)
+ gpu = _GpuPlan(make_cmd, width, height, body.fps)
+ return cmd, duration, retry, lanes, start, gpu
+
+
+@router.post("/{job_id}/takes/{take_id}/render", status_code=202)
+async def start_render(job_id: str, take_id: str, request: Request) -> JSONResponse:
+ """Render the take's camera over the backing mix plus the take to MP4,
+ in the background: 202, then GET .../render follows it."""
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ body: RenderBody = await _parse_body(request, RenderBody, empty_ok=True)
+ camera = await asyncio.to_thread(camera_file, folder)
+ if camera is None:
+ raise HTTPException(status_code=404, detail="take has no camera")
+ if not await asyncio.to_thread(libx264_available):
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot encode H.264 video (no libx264)"
+ )
+ if not await asyncio.to_thread(aac_available):
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot encode H.264 video (no AAC encoder)"
+ )
+ if body.span == "range" and (
+ body.range_in is None or body.range_out is None or body.range_out <= body.range_in
+ ):
+ raise HTTPException(status_code=422, detail="range needs range_in before range_out")
+ audio = await asyncio.to_thread(
+ _render_lanes, job_id, take_id, body, folder / "audio.wav", entry.get("track_id") or None
+ )
+ key = (job_id, take_id)
+ run = _RenderRun()
+ async with _lock_for(job_id, take_id):
+ # Under the take's lock: a delete of the take (or of its job) that ran
+ # while the body was read is seen here, not by ffmpeg later.
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ if _render_busy(job_id, take_id):
+ raise HTTPException(status_code=409, detail="video render already running")
+ # The last render is set aside first: a cancel then reports "none",
+ # never "done" with an older file, and the new file has its name free.
+ # It is only deleted once the new render is accepted (#789).
+ kept = await asyncio.to_thread(_set_aside_old_render, folder)
+ if kept is False:
+ raise HTTPException(
+ status_code=409,
+ detail="the last video is still being saved: try again when it ends",
+ )
+ # Registered (queued) before the lock is let go, so a second POST
+ # gets the same 409, and Cancel (or a delete) can reach it.
+ _renders[key] = run
+ try:
+ cmd, duration, retry, lanes, start, run.gpu = await _prepare_render(
+ job_id, take_id, folder, body, camera, audio
+ )
+ except BaseException:
+ # Refused (or failed) before anything rendered: the earlier video
+ # comes back, unless a cancel, a delete or an edit took the run.
+ if _renders.get(key) is run:
+ if kept is not None and not run.cancel.is_set():
+ await asyncio.to_thread(_restore_old_render, kept, folder)
+ _renders.pop(key, None)
+ elif kept is not None:
+ await asyncio.to_thread(_discard_old_render, kept)
+ raise
+ if kept is not None:
+ await asyncio.to_thread(_discard_old_render, kept)
+ if run.cancel.is_set():
+ # Cancelled while it was being prepared.
+ return JSONResponse(_render_view(job_id, take_id, folder), status_code=202)
+ task = asyncio.create_task(_render_task(run, job, cmd, folder / _RENDER_FILE, duration, retry))
+ _render_tasks.add(task)
+ task.add_done_callback(_render_tasks.discard)
+ width, height = canvas_size(body.canvas, body.size)
+ logger.info(
+ "[%s] take %s video render queued (%d lanes, %s span %.3f s + %.3f s, %dx%d at %d fps)",
+ job_id,
+ take_id,
+ len(lanes),
+ body.span,
+ start,
+ duration,
+ width,
+ height,
+ body.fps,
+ )
+ return JSONResponse(_render_view(job_id, take_id, folder), status_code=202)
+
+
+def _range_outside_take(
+ sidecar: dict[str, Any], song_sec: float, range_in: float | None, range_out: float | None
+) -> bool:
+ """Whether a trim lies wholly before or after the take's own span."""
+ t_start, t_len = _render_span(sidecar, song_sec, "take")
+ return float(range_out or 0.0) <= t_start or float(range_in or 0.0) >= t_start + t_len
+
+
+def _set_aside_old_render(folder: Path) -> Path | None | Literal[False]:
+ """Move the take's last render out of its name, to a temp name the
+ startup sweep removes. The new path, None when there was none, False
+ when it cannot move (Windows: a download of it still has it open)."""
+ src = folder / _RENDER_FILE
+ kept = folder / f".{_RENDER_FILE}.{uuid.uuid4().hex}.tmp.mp4"
+ try:
+ os.replace(src, kept)
+ except FileNotFoundError:
+ return None
+ except PermissionError:
+ return False
+ return kept
+
+
+def _restore_old_render(kept: Path, folder: Path) -> None:
+ """Put a set-aside render back under its name (the new render was
+ refused before it started)."""
+ try:
+ os.replace(kept, folder / _RENDER_FILE)
+ except OSError:
+ logger.warning("earlier video not restored: %s", kept.name)
+ _discard_old_render(kept)
+
+
+def _discard_old_render(kept: Path) -> None:
+ """Delete a set-aside render (the new one was accepted, or cancelled).
+ One that cannot go now is removed by the startup sweep."""
+ try:
+ kept.unlink(missing_ok=True)
+ except OSError:
+ logger.warning("earlier video not removed: %s", kept.name)
+
+
+@router.get("/{job_id}/takes/{take_id}/render")
+async def get_render(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ return _render_view(job_id, take_id, folder)
+
+
+@router.post("/{job_id}/takes/{take_id}/render/cancel")
+async def cancel_render(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ run = _renders.pop((job_id, take_id), None)
+ if run is not None:
+ _stop_run(run)
+ return _render_view(job_id, take_id, folder)
+
+
+# What a download name may not contain on any file system.
+_UNSAFE_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f\x7f]')
+
+
+def _requested_name(name: str | None) -> str | None:
+ """A download name the page asked for (Streamer's " -
+ (Streamer).mp4"), made safe, or None to use the default. Only ever a
+ Content-Disposition value: it names nothing on the server.
+
+ Control characters and Unicode format characters (category Cf: the bidi
+ overrides that can make "gpj.exe" read as "exe.jpg", zero-width marks)
+ go, then the file-system characters, then spaces and dots at the ends."""
+ if not name:
+ return None
+ visible = "".join(c for c in name if unicodedata.category(c) not in ("Cc", "Cf"))
+ clean = re.sub(r"\s+", " ", _UNSAFE_NAME.sub(" ", visible)).strip().strip(".").strip()
+ if not clean.lower().endswith(".mp4") or len(clean) <= 4 or len(clean) > 160:
+ return None
+ return clean
+
+
+@router.get("/{job_id}/takes/{take_id}/render.mp4", response_model=None)
+async def get_render_file(
+ job_id: str, take_id: str, name: str | None = Query(default=None, max_length=200)
+) -> FileResponse:
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ if _render_busy(job_id, take_id):
+ # The file is about to be replaced: never half of one, or the old one.
+ raise HTTPException(status_code=409, detail="video render running")
+ path = (folder / _RENDER_FILE).resolve()
+ if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="no video render")
+ filename = _requested_name(name) or _download_name(job, entry["label"], "mp4")
+ return FileResponse(path, media_type="video/mp4", filename=filename)
diff --git a/app/api/video_encoders.py b/app/api/video_encoders.py
new file mode 100644
index 00000000..d61ec5df
--- /dev/null
+++ b/app/api/video_encoders.py
@@ -0,0 +1,102 @@
+"""The take render's video encoders, for the Settings "Video encoder" control.
+
+GET /api/video-encoders the policy, this OS's candidates, what the
+ probe cache already knows (never probes)
+POST /api/video-encoders/test probe every candidate afresh at 1080p30 and
+ 4K30, plus libx264 for comparison
+
+Nothing from the request reaches a command: the test takes no input, and the
+encoder names in every command come from config.VIDEO_GPU_ENCODERS. One test
+at a time (409 while one runs), bounded by VIDEO_ENCODER_TEST_BUDGET_SEC.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import sys
+
+from fastapi import APIRouter, HTTPException
+
+from app.core.config import VIDEO_CPU_ENCODER, VIDEO_ENCODER_TEST_BUDGET_SEC
+from app.core.settings import get_video_encoder
+from app.pipeline import video_encoder
+
+logger = logging.getLogger("stemdeck.api")
+
+router = APIRouter()
+
+_test_lock = asyncio.Lock()
+
+
+def _summary(version: str) -> dict:
+ value, source = video_encoder.policy()
+ cands = list(video_encoder.candidates())
+ return {
+ # The stored setting (what the select shows) and what renders use:
+ # STEMDECK_VIDEO_ENCODER wins over the setting when it is set.
+ "setting": get_video_encoder(),
+ "effective": value,
+ "source": source,
+ "candidates": cands,
+ "families": {name: video_encoder.FAMILY[name] for name in [*cands, VIDEO_CPU_ENCODER]},
+ "disabled": video_encoder.disabled(),
+ "ffmpeg": version,
+ "os": video_encoder.os_key(),
+ "platform": sys.platform,
+ }
+
+
+def _cached_rows() -> list[dict]:
+ rows: dict[str, dict] = {}
+ sizes = {(w, h, f): key for key, (w, h, f) in video_encoder.TEST_SIZES.items()}
+ for result in video_encoder.cached_results():
+ size = sizes.get((result.width, result.height, result.fps))
+ if size is None:
+ continue
+ row = rows.setdefault(
+ result.encoder,
+ {
+ "encoder": result.encoder,
+ "family": video_encoder.FAMILY[result.encoder],
+ "disabled": result.encoder in video_encoder.disabled(),
+ "results": {},
+ },
+ )
+ row["results"][size] = result.view()
+ order = list(video_encoder.candidates())
+ return sorted(
+ rows.values(), key=lambda r: order.index(r["encoder"]) if r["encoder"] in order else 99
+ )
+
+
+@router.get("/video-encoders")
+async def get_video_encoders() -> dict:
+ try:
+ _ident, version = await asyncio.to_thread(video_encoder.ffmpeg_identity)
+ rows = await asyncio.to_thread(_cached_rows)
+ except Exception:
+ logger.exception("video encoder listing failed")
+ raise HTTPException(status_code=500, detail="video encoders could not be listed") from None
+ return {**_summary(version), "results": rows, "tested": False}
+
+
+@router.post("/video-encoders/test")
+async def test_video_encoders() -> dict:
+ if _test_lock.locked():
+ raise HTTPException(status_code=409, detail="an encoder test is already running")
+ async with _test_lock:
+ try:
+ _ident, version = await asyncio.to_thread(video_encoder.ffmpeg_identity)
+ rows = await asyncio.wait_for(
+ asyncio.to_thread(video_encoder.test_all, VIDEO_ENCODER_TEST_BUDGET_SEC),
+ # The budget stops new probes; this bounds the request even if
+ # the last probe hangs past it (each probe has its own timeout).
+ timeout=VIDEO_ENCODER_TEST_BUDGET_SEC + 60,
+ )
+ except TimeoutError:
+ raise HTTPException(status_code=504, detail="the encoder test took too long") from None
+ except Exception:
+ logger.exception("video encoder test failed")
+ raise HTTPException(status_code=500, detail="the encoder test failed") from None
+ return {**_summary(version), "results": rows, "tested": True}
diff --git a/app/core/config.py b/app/core/config.py
index 63ebf7c4..cd446563 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -72,6 +72,23 @@ def available_torch_devices() -> list[str]:
return devices
+def torch_gpu_backend(devices: list[str]) -> str | None:
+ """What drives the "cuda" device in `devices` (an available_torch_devices()
+ result): "cuda" for NVIDIA, "rocm" for AMD, None without one (#825).
+
+ A ROCm build of torch serves an AMD GPU through the same torch.cuda API, so
+ the device is "cuda" either way and every device setting, job record and
+ worker stays as it is; only what the user is shown has to tell them apart.
+ Read off the build (torch.version.hip is set on ROCm only), never by asking
+ the card: GPU work belongs to the worker processes. Takes the probe's
+ result rather than probing again."""
+ if "cuda" not in devices:
+ return None
+ import torch
+
+ return "rocm" if getattr(torch.version, "hip", None) else "cuda"
+
+
def detect_torch_device() -> str:
"""Best available Torch device for Demucs by hardware probe: cuda > mps >
cpu. Apple Silicon needs the explicit MPS check -- demucs's CLI default is
@@ -86,6 +103,12 @@ def detect_torch_device() -> str:
ROOT = Path(__file__).resolve().parent.parent.parent
STATIC_DIR = ROOT / "static"
+# The click-sound presets (#805), defined once for the browser and the export
+# renderer: app/pipeline/click_sounds.py parses the JSON block in this file.
+CLICK_SOUNDS_TABLE = STATIC_DIR / "js" / "clickSounds.js"
+# The isolated click track's name in the stems zip (#805). Never a stem name:
+# asserted in app/api/stems.py, so the click cannot overwrite a stem's entry.
+CLICK_STEM_NAME = "click"
STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other")
# Produced only when a job opts into the lead/backing vocal split (best-effort,
# additive -- see app/pipeline/vocal_split.py). Kept out of STEM_NAMES itself:
@@ -93,7 +116,9 @@ def detect_torch_device() -> str:
# GET /api/config contract all assume "the 6 Demucs stems" and must not change
# just because a job happened to request this extra pass.
EXTRA_STEM_NAMES: tuple[str, ...] = ("lead_vocals", "backing_vocals")
-JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$")
+JOB_ID_RE = re.compile(r"^[a-f0-9]{12}\Z")
+# The file types an upload may be. A job keeps its upload as source.
+UPLOAD_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus"))
def _packaged_data_dir() -> Path | None:
@@ -206,6 +231,23 @@ def _stored_jobs_dir() -> Path | None:
# listener alongside the first; when it is not, there is only ever one server
# and TLS (if configured at all) belongs to whoever launched uvicorn.
HTTPS_PORT = _env_int("STEMDECK_HTTPS_PORT", 0) or None
+# The address the desktop shell told uvicorn to bind: 127.0.0.1 while network
+# access is off, 0.0.0.0 while it is on (#859). None when anything else
+# started the server, which decides its own bind.
+BIND_HOST = os.environ.get("STEMDECK_BIND_HOST", "").strip() or None
+
+
+def _env_csv(name: str) -> tuple[str, ...]:
+ """A comma-separated list setting, lowercased, blanks dropped."""
+ raw = os.environ.get(name, "")
+ return tuple(part.strip().lower() for part in raw.split(",") if part.strip())
+
+
+# Extra names this server answers to, for the cross-site guard in
+# app/core/csrf.py: the public name of a reverse proxy, a Docker service name, a
+# Tailscale name. Host names only, a port is ignored; "*" accepts any Host.
+# When set, the Host check is strict even with network access on (see csrf.py).
+ALLOWED_HOSTS = _env_csv("STEMDECK_ALLOWED_HOSTS")
FFMPEG_BIN = _env_path(
"STEMDECK_FFMPEG",
FFMPEG_DIR / ("ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"),
@@ -293,10 +335,39 @@ def js_solver_available() -> bool:
DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s"
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
+# The library list keeps each song's files-present answer until its stems
+# folder changes (#976), keyed on the folder's modification time. A folder
+# changed more recently than this is never trusted, because a filesystem that
+# keeps times coarsely could leave the time unchanged by a change in the same
+# tick. FAT keeps two seconds, so this never goes below that.
+_LIBRARY_RACY_WINDOW_FLOOR_MS = 2000
+
+
+def _library_racy_window_ns() -> int:
+ """STEMDECK_LIBRARY_RACY_WINDOW_MS, raised to the floor with a warning
+ when set below it: a smaller window would trust a folder time that a
+ coarse filesystem has not moved yet. Read once, at import."""
+ window_ms = _env_int("STEMDECK_LIBRARY_RACY_WINDOW_MS", 3000)
+ if window_ms < _LIBRARY_RACY_WINDOW_FLOOR_MS:
+ logger.warning(
+ "STEMDECK_LIBRARY_RACY_WINDOW_MS=%d is below the %d ms floor; using %d ms",
+ window_ms,
+ _LIBRARY_RACY_WINDOW_FLOOR_MS,
+ _LIBRARY_RACY_WINDOW_FLOOR_MS,
+ )
+ window_ms = _LIBRARY_RACY_WINDOW_FLOOR_MS
+ return window_ms * 1_000_000
+
+
+LIBRARY_RACY_WINDOW_NS = _library_racy_window_ns()
# TTL for quarantined failed-job dirs (jobs/failed/, kept for diagnostics).
# Swept unconditionally -- even deployments with a persistent library must not
# accumulate failure evidence forever.
FAILED_TTL_SECONDS = max(3600, _env_int("STEMDECK_FAILED_TTL_SECONDS", 7 * 24 * 3600)) # 7 d
+# How long an export cache temp must have gone unwritten before the startup
+# sweep takes it. A second server that shares the cache folder (one started
+# with only STEMDECK_JOBS_DIR changed) may be writing it; an orphan is older.
+CACHE_TEMP_MIN_AGE_SECONDS = 600
# Depth of the import queue: jobs waiting for their turn, not counting the one
# running. Counted separately by kind, because the two cost wildly different
# things. A queued upload holds its source file on disk for the whole wait, so
@@ -310,9 +381,43 @@ def js_solver_available() -> bool:
# normalization. Unrelated to MAX_PENDING_JOBS, which bounds the queue itself --
# a playlist larger than the queue has room for fills what it can and says so.
PLAYLIST_MAX_ITEMS = max(1, min(200, _env_int("STEMDECK_PLAYLIST_MAX_ITEMS", 50)))
+# Old songs waiting for their loudness to be measured again (api/jobs.py), at up
+# to TIMEOUT_ANALYZE each. One opened past this is measured on a later open.
+LOUDNESS_UPGRADE_QUEUE_MAX = 8
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)
+# The separation model's first download (#125). A network that opens the
+# connection and then sends nothing used to hold the first Split for the whole
+# 30-minute stall limit above. A read gives up after this many seconds without
+# a byte, the file is tried this many times with these pauses between tries,
+# and a worker silent this long while it downloads is stopped. The worker says
+# each failed try on stderr, which resets that limit, so its own error, which
+# names the file and the folder, usually arrives first; when the limit fires
+# instead (a host whose addresses each swallow the connection, say), the job's
+# error names them too.
+TIMEOUT_MODEL_DOWNLOAD_READ = max(1, _env_int("STEMDECK_TIMEOUT_MODEL_DOWNLOAD_READ", 30))
+MODEL_DOWNLOAD_ATTEMPTS = max(1, _env_int("STEMDECK_MODEL_DOWNLOAD_ATTEMPTS", 3))
+MODEL_DOWNLOAD_BACKOFF = (2, 5)
+TIMEOUT_MODEL_STALL = max(1, _env_int("STEMDECK_TIMEOUT_MODEL_STALL", 120))
+# Minutes with no separation or analysis running before the idle Demucs
+# worker is stopped and the beat model dropped (#975); 0 never unloads. The worker exists to skip a model
+# load per job (#309), so this trades memory for the next job's start: measured
+# on an RTX 3080, 2026-11-05, a 3 min 53 s song: the idle CUDA worker held
+# 1.93 GB of RAM and 1.11 GB of VRAM (CPU worker: 1.46 GB of RAM), and the
+# first separation after an unload took 5.8 s longer (9.9 s against 15.7 s).
+# Ten minutes keeps a run of imports warm and gives the memory back once the
+# user has stopped. The Settings value wins over the environment.
+IDLE_UNLOAD_MINUTES_MAX = 1440
+
+
+def _env_idle_unload_minutes() -> int:
+ return max(0, min(IDLE_UNLOAD_MINUTES_MAX, _env_int("STEMDECK_IDLE_UNLOAD_MINUTES", 10)))
+
+
+IDLE_UNLOAD_MINUTES = _env_idle_unload_minutes()
+# How often the idle timer looks. An unload lands at most this late.
+IDLE_UNLOAD_CHECK_SEC = 30
# Automatic functional-section analysis. Inference stays on CPU because the
# persistent Demucs worker deliberately keeps its model resident on the chosen
# accelerator between jobs; loading a second model beside it would make VRAM
@@ -348,10 +453,186 @@ def js_solver_available() -> bool:
# A first run downloads the checkpoint (hundreds of MB); generous default so a
# slow connection isn't mistaken for a stall.
TIMEOUT_VOCAL_SPLIT = _env_int("STEMDECK_TIMEOUT_VOCAL_SPLIT", 1800)
+# How often the split's stall watchdog looks. Coarse on purpose: the timeout
+# above is half an hour.
+VOCAL_SPLIT_WATCHDOG_POLL_SEC = 30
# Beat-grid stage decodes the whole drums stem (not the 180 s analyze window),
# so it gets its own, larger budget.
TIMEOUT_BEATGRID = _env_int("STEMDECK_TIMEOUT_BEATGRID", 300)
+# Recorded takes (docs: audio capture). A take is a folder under
+# jobs//takes// and lives and dies with the job folder.
+TAKE_ID_RE = re.compile(r"^t[0-9a-f]{8}\Z")
+TAKES_DIRNAME = "takes"
+# One job can hold this many takes, finished or not. A bound on disk use by a
+# client that creates takes in a loop, not a musical limit.
+TAKE_MAX_PER_JOB = 50
+# Recording tracks (DAW-style): a track is one mixer lane that owns takes and
+# plays its active one. Stored in takes/tracks.json beside the take folders.
+TRACK_ID_RE = re.compile(r"^k[0-9a-f]{8}\Z")
+TRACKS_FILE = "tracks.json"
+TRACK_MAX_PER_JOB = 32
+# A video take's filmstrip (GET .../takes/{id}/filmstrip): one sprite of
+# frames, this tall, at most this many, from ffmpeg within this long.
+FILMSTRIP_FRAME_HEIGHT = 72
+FILMSTRIP_MAX_FRAMES = 240
+TIMEOUT_FILMSTRIP = _env_int("STEMDECK_TIMEOUT_FILMSTRIP", 120)
+# A part body is capped by the 4 MB JSON body middleware in app/main.py; the
+# route checks the same number while it streams the body, because the header
+# can be absent or wrong.
+TAKE_PART_MAX_BYTES = 4 * 1024 * 1024
+TAKE_MAX_SEQ = 100_000
+# Total size per kind is bounded by the song: its duration plus this much
+# slack (count-in, pickup notes, a late Stop), at the largest capture format.
+TAKE_SLACK_SEC = 120
+TAKE_AUDIO_MAX_BYTES_PER_SEC = 48000 * 2 * 4 # float32 stereo at 48 kHz
+TAKE_CAMERA_MAX_BYTES_PER_SEC = 20_000_000 // 8 # 20 Mbit/s, 2.5x the 8 Mbit/s recorder
+# Streamer Mode's recorder asks for at most 70% of that (recordModel.js
+# BITRATE_MAX), so a busy scene's overshoot still fits.
+# A take render's output: the canvas's short side for each size it offers.
+# No 4K (the maintainer's decision): a 1080p camera only gets bigger, at a
+# large cost in encode time and memory.
+CANVAS_SHORT_SIDES = {"720": 720, "1080": 1080}
+# The click's level in a take render: the export's default (stems.py).
+RENDER_CLICK_GAIN = 0.6
+# A backing track without one stem (app/pipeline/backing.py): a sum of five
+# WAVs, seconds for a song; the ceiling only guards a stuck disk.
+TIMEOUT_BACKING = _env_int("STEMDECK_TIMEOUT_BACKING", 120)
+
+# How a finished song's stems are kept on disk (#783): "wav" (16-bit PCM, as
+# separated) or "mp3" (CBR libmp3lame at one of STEM_MP3_KBPS_CHOICES). The
+# default for an install with no saved choice and no env var. One line to
+# change. 128 kbps is the maintainer's choice: stems summed again in the
+# mixer carry the coding noise of each one.
+DEFAULT_STEM_STORAGE: tuple[str, int] = ("mp3", 128)
+STEM_FORMAT_CHOICES: tuple[str, ...] = ("wav", "mp3")
+STEM_MP3_KBPS_CHOICES: tuple[int, ...] = (128, 192, 256)
+# The MP3 step at the end of the pipeline (app/pipeline/stem_storage.py): one
+# ffmpeg per stem, a few seconds each. Terminated when ffmpeg is silent this
+# long; the song then keeps its WAVs.
+TIMEOUT_STEM_TRANSCODE_STALL = _env_int("STEMDECK_TIMEOUT_STEM_TRANSCODE_STALL", 120)
+# Video render of a take (libx264 + AAC). Slower than any audio export: a
+# 5-minute 1080p30 encode runs about 1x to 3x realtime on a desktop CPU, and a
+# small NAS or laptop CPU can be several times slower than realtime. Bounded by
+# this many seconds per second of video, within the floor and the ceiling (the
+# floor wins): a flat bound cut a long take short on a slow CPU.
+TIMEOUT_VIDEO_RENDER = _env_int("STEMDECK_TIMEOUT_VIDEO_RENDER", 3600)
+TIMEOUT_RENDER_MAX = _env_int("STEMDECK_TIMEOUT_RENDER_MAX", 6 * 3600)
+VIDEO_RENDER_SEC_PER_SEC = 6.0
+
+# The take render's hardware (GPU) H.264 encoders (app/pipeline/video_encoder.py).
+# libx264 stays the default and the fallback for every failure. h264_mf
+# (Windows Media Foundation) is deliberately absent: it refused 4K on the test
+# machine and has no constant-quality mode.
+VIDEO_CPU_ENCODER = "libx264"
+VIDEO_GPU_ENCODERS = ("h264_nvenc", "h264_qsv", "h264_amf", "h264_vaapi", "h264_videotoolbox")
+# The setting's values: "auto" tries the GPU encoders, "cpu" never does, an
+# encoder name tries only that one.
+VIDEO_ENCODER_CHOICES = ("auto", "cpu", *VIDEO_GPU_ENCODERS)
+
+
+def _env_choice(name: str, choices: tuple[str, ...]) -> str:
+ """An env value from a fixed list, lowercased; "" when unset or unknown."""
+ raw = os.environ.get(name, "").strip().lower()
+ return raw if raw in choices else ""
+
+
+# STEMDECK_VIDEO_ENCODER=cpu is the kill switch: it wins over the setting.
+# Any other choice ("auto", an encoder name) also wins; "" leaves the setting.
+VIDEO_ENCODER_OVERRIDE = _env_choice("STEMDECK_VIDEO_ENCODER", VIDEO_ENCODER_CHOICES)
+
+
+def _env_render_node(name: str) -> str:
+ """A DRM render node from the env ("renderD129" or "/dev/dri/renderD129")
+ as its full path; "" when unset. Anything else is ignored with a warning,
+ so a typo never reaches an ffmpeg command."""
+ raw = os.environ.get(name, "").strip()
+ if not raw:
+ return ""
+ match = re.fullmatch(r"(?:/dev/dri/)?(renderD[0-9]+)", raw)
+ if match is None:
+ logger.warning(
+ "%s=%r is not a render node such as renderD128 or /dev/dri/renderD128; ignored",
+ name,
+ raw[:64],
+ )
+ return ""
+ return f"/dev/dri/{match.group(1)}"
+
+
+# h264_vaapi tries every /dev/dri/renderD* node in number order and uses the
+# first whose test encode passes (#995: on a hybrid-GPU machine the first node
+# can be the one without a working encoder). STEMDECK_VAAPI_DEVICE pins it to
+# one node instead, with no other node tried; that node missing means no VAAPI.
+VAAPI_DEVICE_OVERRIDE = _env_render_node("STEMDECK_VAAPI_DEVICE")
+
+# A probe is a real 2 s encode of a test picture at the render's size and rate.
+VIDEO_ENCODER_PROBE_SEC = 2
+TIMEOUT_VIDEO_ENCODER_PROBE = _env_int("STEMDECK_TIMEOUT_VIDEO_ENCODER_PROBE", 20)
+# `ffmpeg -encoders` for the audio export capabilities (app/pipeline/
+# audio_encoders.py). Runs once per ffmpeg binary and returns in well under a
+# second; the bound only matters for an ffmpeg that hangs.
+TIMEOUT_AUDIO_ENCODER_LIST = _env_int("STEMDECK_TIMEOUT_AUDIO_ENCODER_LIST", 15)
+# `ffmpeg -filters`, asked once per ffmpeg binary whether it has rubberband,
+# which a transposed export needs (app/pipeline/audio_encoders.py, #858).
+TIMEOUT_AUDIO_FILTER_LIST = _env_int("STEMDECK_TIMEOUT_AUDIO_FILTER_LIST", 15)
+# `nvidia-smi`, asked why separation runs on the CPU (app/core/device_reason.py,
+# #247). About 0.2 s on an RTX 3080; the first call can take seconds while an
+# Optimus laptop wakes its GPU. The answer is kept for NVIDIA_SMI_TTL_SEC, since
+# Settings asks on every open and a GPU does not come and go within minutes.
+TIMEOUT_NVIDIA_SMI = _env_int("STEMDECK_TIMEOUT_NVIDIA_SMI", 10)
+NVIDIA_SMI_TTL_SEC = 300
+# How long a probe result is trusted. A pass for a week, a failure for a day (a
+# driver installed today is tried tomorrow, or at once with Test encoders), a
+# timeout or a busy device for ten minutes (never as a lasting answer).
+VIDEO_ENCODER_PASS_TTL_SEC = 7 * 24 * 3600
+VIDEO_ENCODER_FAIL_TTL_SEC = 24 * 3600
+VIDEO_ENCODER_RETRY_SEC = 600
+# Failed GPU renders before that encoder is left alone until the next start.
+VIDEO_ENCODER_MAX_FAILURES = 2
+# A GPU render whose progress has not moved for this long is stopped and made
+# again on the CPU (a hung driver never ends on its own).
+TIMEOUT_VIDEO_GPU_STALL = _env_int("STEMDECK_TIMEOUT_VIDEO_GPU_STALL", 60)
+# The check of a GPU file: a full decode, bounded by this floor or this many
+# seconds per second of video, whichever is longer.
+TIMEOUT_VIDEO_VALIDATE_MIN = _env_int("STEMDECK_TIMEOUT_VIDEO_VALIDATE", 120)
+VIDEO_VALIDATE_SEC_PER_SEC = 1.0
+VIDEO_VALIDATE_DURATION_TOL_SEC = 0.5
+# The Settings "Test encoders" run: every candidate at 1080p30 and 4K30,
+# stopped when this much time has gone.
+VIDEO_ENCODER_TEST_BUDGET_SEC = 150
+# The GPU encoders' bitrate ceiling, in bits per pixel per frame (31 Mbps at
+# 1080p30, 124 Mbps at 4K30). Measured on the RTX 3080: NVENC at cq 19 spent
+# 0.5 to 0.9 Mbps on a real 1080p scene take (libx264 crf 20: 0.34) with a
+# higher SSIM, and on pure noise topped out near 35 Mbps whatever the cap, so
+# the cap only guards a runaway file.
+VIDEO_GPU_MAX_BITS_PER_PIXEL = 0.5
+
+# Ceilings of the limiter at the end of every mixdown (export clipping fix).
+# Stems summed at unity measured +0.9 dBFS with 77 clipped samples on a real
+# song (docs/plans 01-mastering 3.3).
+#
+# Lossless exports (WAV, FLAC): a sample-peak limit at -1 dBFS, idle and
+# bit-identical below it. Measured on two real songs with +6 dB boosts, a
+# hot take, transpose and click: sample peak -1.0 dBFS, true peak -0.6 dBTP
+# at worst, no sample at full scale, also after resampling to 48 kHz.
+EXPORT_LIMIT_DBFS = -1.0
+# Lossy exports (MP3, OGG, the AAC in an MP4): the encoder adds peak on top
+# of its input (MP3 measured +0.7 dB in the same document, 4.1), so the
+# limit is a true-peak one (4x oversampled) and lower. Measured on the same
+# songs: decoded peaks -0.9 to -1.3 dBTP, where the -1 dBFS sample limit
+# left -0.4 dBTP for MP3 and OGG and +0.4 dBFS (clipped) for AAC.
+EXPORT_LIMIT_LOSSY_DBTP = -1.5
+
+# Every loudness meter (ffmpeg's ebur128) is run with this framelog level.
+# Without one, ebur128 logs a line per 100 ms frame at "info". "verbose" moves
+# those lines below ffmpeg's default "info" level, so only the summary and any
+# printed metadata reach stderr. Not "quiet": ffmpeg 6.1 knows that value but
+# 5.1 (Debian 12, the oldest StemDeck runs on: MIN_FFMPEG_VERSION in
+# app/pipeline/audio_encoders.py) does not, and it rejects the whole filter
+# graph, so nothing is measured at all.
+EBUR128_FRAMELOG = "verbose"
+
# Reading an upload's tags (artist, title, album, lyrics) with ffprobe for the
# Lyrics tab and the artist box (#699). Tags are a nicety, so a probe that
# takes longer than this is abandoned and the upload goes ahead without them.
@@ -383,10 +664,6 @@ def js_solver_available() -> bool:
# lookup runs beside separation, which takes far longer, so it costs an import
# no time unless both requests are slow and the separation was fast.
TIMEOUT_ARTIST_LOOKUP = _env_int("STEMDECK_TIMEOUT_ARTIST_LOOKUP", 8)
-# How long a finished pipeline waits for a lookup still in flight before it
-# lets the job finish without a band. Kept short: the page finds the band
-# itself when it opens a track that has none, so the wait buys little.
-ARTIST_LOOKUP_GRACE_SEC = _env_int("STEMDECK_ARTIST_LOOKUP_GRACE_SEC", 2)
# Wikimedia asks every client to name itself and a way to reach its maker.
ARTIST_LOOKUP_USER_AGENT = (
"StemDeck (https://github.com/stemdeckapp/stemdeck; self-hosted stem separation)"
@@ -488,9 +765,8 @@ def _musicbrainz_user_agent() -> str:
IDENTIFY_VIDEO_EXTRA_SEC = 150
# Each AcoustID or MusicBrainz request is abandoned after this many seconds.
TIMEOUT_IDENTIFY_REQUEST = _env_int("STEMDECK_TIMEOUT_IDENTIFY_REQUEST", 8)
-# How long a finished pipeline waits for identification still in flight, as
-# ARTIST_LOOKUP_GRACE_SEC does for the band. Separation takes minutes, so the
-# answer is nearly always waiting already.
+# How long a finished pipeline waits for identification still in flight.
+# Separation takes minutes, so the answer is nearly always waiting already.
IDENTIFY_GRACE_SEC = _env_int("STEMDECK_IDENTIFY_GRACE_SEC", 2)
# POST /api/jobs/{id}/audio-tags identifies an older track before it answers;
# past this it answers with what it has.
@@ -866,6 +1142,14 @@ def _musicbrainz_user_agent() -> str:
VIDEO_MAX_HEIGHT = max(144, _env_int("STEMDECK_VIDEO_MAX_HEIGHT", 720))
+# Output options for every WAV ffmpeg writes. Without them the muxer inserts a
+# LIST/INFO chunk (ISFT "Lavf...", plus any tags carried over from the input)
+# between fmt and data. That is valid RIFF, but hardware players and samplers
+# that only parse the canonical 44-byte header refuse the file. Both flags are
+# needed: -map_metadata -1 drops inherited tags, +bitexact drops the encoder tag.
+WAV_PLAIN_HEADER_ARGS = ("-map_metadata", "-1", "-fflags", "+bitexact")
+
+
def ffmpeg_executable() -> str:
"""Return the preferred FFmpeg executable.
diff --git a/app/core/csrf.py b/app/core/csrf.py
new file mode 100644
index 00000000..85d116ce
--- /dev/null
+++ b/app/core/csrf.py
@@ -0,0 +1,306 @@
+"""Cross-site request guard: the browser-facing half of "no authentication".
+
+StemDeck has no login by design (see the `security` skill). That is safe only
+while the one thing that can talk to it is the user's own browser tab on
+StemDeck's own page. Without this guard, any web page the user visits can talk
+to it too:
+
+- A simple cross-site POST needs no CORS permission. `fetch(url, {mode:
+ "no-cors", method: "POST", body})` sends a text/plain body that the browser
+ never asks about, and the handlers that call `request.json()` used to parse it
+ regardless of the Content-Type. POST /api/reset wiped the library, POST
+ /api/settings turned network access on, and on the desktop POST
+ /api/settings/stems-location moved the library to a folder the page chose.
+- DNS rebinding turns that into read access too: evil.example resolves first to
+ the attacker and then to 127.0.0.1, so the attacker's page is "same origin"
+ with StemDeck in the browser's eyes. Only the Host header tells them apart.
+
+Three checks, each cheap and each about one of those holes:
+
+1. Unsafe methods (POST, PUT, PATCH, DELETE) from another site are refused.
+ `Sec-Fetch-Site: cross-site` is refused outright, and `same-origin` is
+ accepted outright: the browser computes it and a page cannot forge it. For a
+ browser that sends no Sec-Fetch headers (older Safari), an `Origin` header,
+ which browsers always send on a cross-origin POST, must name this server.
+ A request with neither header is not from a web page: curl, the Rust shell,
+ Python clients and the test suite. Those stay allowed, because CSRF is an
+ attack through a victim's browser and they have nothing to protect.
+
+2. The Host header must be a name this server answers to. This is the DNS
+ rebinding check, and it is deliberately scoped:
+ - Network access OFF (the desktop default): only the host machine is served
+ anyway, so the legitimate names are known. Loopback (localhost, *.localhost,
+ 127.0.0.0/8, ::1), the machine's own IPs and its own host names.
+ - Network access ON (the server, Docker and Unraid default): any Host is
+ accepted. A Docker container cannot know the address the user typed
+ (http://:8000 arrives as a Host that is none of the container's
+ own IPs), and a reverse proxy may forward any public name. Refusing those
+ would lock real users out on upgrade, and a LAN-reachable server with no
+ authentication is already readable by every device on that LAN.
+ - STEMDECK_ALLOWED_HOSTS set: the list is strict, with network access on or
+ off, on top of the names above. That is the opt-in for a proxied or Docker
+ install that wants the rebinding protection too. "*" disables the check.
+
+3. A body sent to /api with a Content-Type must be JSON, except on the two
+ routes that take something else (the multipart file import and the raw
+ take-part upload). This is the belt to check 1's braces: text/plain and form
+ bodies are exactly what a no-cors page can send, and no StemDeck route reads
+ one. A body with no Content-Type is let through; see _content_type_ok.
+
+Same-origin is judged against the Host header, or X-Forwarded-Host when a proxy
+set it. A cross-site page cannot add that header: it is not a simple header, so
+the browser would first send a CORS preflight, which StemDeck never answers.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import re
+import socket
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from functools import lru_cache
+from urllib.parse import urlsplit
+
+UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
+
+# Origins of the desktop shell's own pages (its setup screen), never of the
+# main window, which loads http://127.0.0.1: and is plainly same-origin.
+# tauri://localhost on macOS and Linux, http(s)://tauri.localhost on Windows.
+# Accepted only when the Tauri shell spawned this backend.
+TAURI_ORIGINS = frozenset(
+ {"tauri://localhost", "http://tauri.localhost", "https://tauri.localhost"}
+)
+
+# The routes whose body is not JSON. Everything else under /api that carries a
+# body must send application/json (or a +json type).
+_RAW_BODY_ROUTES: tuple[tuple[str, re.Pattern[str], str | None], ...] = (
+ # File import. The same route takes a JSON link import, which the generic
+ # rule covers; only multipart is the extra case.
+ ("POST", re.compile(r"^/api/jobs/?$"), "multipart/form-data"),
+ # A recorded take arrives as raw float32 or video bytes, any type.
+ ("PUT", re.compile(r"^/api/jobs/[^/]+/takes/[^/]+/parts/[^/]+$"), None),
+)
+
+# Kept generic on purpose: the reason is for the log, not for the caller.
+REFUSED_CROSS_SITE = "cross-site request refused"
+REFUSED_HOST = "unknown host"
+REFUSED_CONTENT_TYPE = "Content-Type must be application/json"
+
+
+@dataclass(frozen=True)
+class Refusal:
+ status: int
+ detail: str
+ reason: str # what the log says; never sent to the client
+
+
+def split_host(value: str) -> tuple[str, int | None]:
+ """A Host header (or netloc) as (lowercase host, port or None).
+
+ Handles "[::1]:8000", "::1" is not a valid Host but is tolerated, and a
+ trailing dot ("localhost.") which names the same host."""
+ value = value.strip().lower()
+ port: int | None = None
+ if value.startswith("["):
+ end = value.find("]")
+ if end == -1:
+ return value, None
+ host, rest = value[1:end], value[end + 1 :]
+ if rest.startswith(":") and rest[1:].isdigit():
+ port = int(rest[1:])
+ return host, port
+ if value.count(":") == 1:
+ host, _, raw_port = value.partition(":")
+ if raw_port.isdigit():
+ port = int(raw_port)
+ value = host
+ return value.rstrip("."), port
+
+
+def _default_port(scheme: str) -> int | None:
+ return {"http": 80, "https": 443, "ws": 80, "wss": 443}.get(scheme)
+
+
+def _is_loopback_name(host: str) -> bool:
+ if host == "localhost" or host.endswith(".localhost"):
+ return True
+ try:
+ return ipaddress.ip_address(host.split("%", 1)[0]).is_loopback
+ except ValueError:
+ return False
+
+
+@lru_cache(maxsize=1)
+def own_hostnames() -> frozenset[str]:
+ """The names the machine itself goes by: what a user on the host machine
+ might type instead of localhost. Best-effort; empty on failure."""
+ names: set[str] = set()
+ try:
+ short = socket.gethostname().strip().lower()
+ if short:
+ names.update({short, f"{short}.local"})
+ fqdn = socket.getfqdn().strip().lower()
+ if fqdn:
+ names.add(fqdn)
+ names.add(fqdn.split(".", 1)[0])
+ except OSError:
+ pass
+ return frozenset(n.rstrip(".") for n in names if n)
+
+
+def host_allowed(
+ host: str,
+ *,
+ allow_network: bool,
+ extra_hosts: Iterable[str],
+ local_ips: Iterable[str],
+) -> bool:
+ """Whether `host` (a bare host name, no port) is a name this server answers to.
+
+ See the module docstring for why network access ON and no extra hosts
+ accepts anything."""
+ extra = frozenset(extra_hosts)
+ if "*" in extra:
+ return True
+ if not extra and allow_network:
+ return True
+ if not host:
+ return False
+ if host in extra or _is_loopback_name(host):
+ return True
+ ips = {ip.split("%", 1)[0].lower() for ip in local_ips}
+ if host.split("%", 1)[0] in ips:
+ return True
+ return host in own_hostnames()
+
+
+def _header(headers: Mapping[str, str], name: str) -> str:
+ return (headers.get(name) or "").strip()
+
+
+def _forwarded_host(headers: Mapping[str, str]) -> str:
+ """The client-facing Host a proxy recorded, first hop only."""
+ xfh = _header(headers, "x-forwarded-host")
+ if xfh:
+ return xfh.split(",")[0].strip()
+ rfc7239 = _header(headers, "forwarded")
+ if rfc7239:
+ for part in rfc7239.split(",")[0].split(";"):
+ key, _, value = part.strip().partition("=")
+ if key.lower() == "host":
+ return value.strip().strip('"')
+ return ""
+
+
+def origin_matches(origin: str, headers: Mapping[str, str], extra_hosts: Iterable[str]) -> bool:
+ """Whether `origin` is this server, as the browser was addressing it.
+
+ The origin's host must equal the Host header's (or the proxy's forwarded
+ host). Ports must agree when the Host header names one, so a different
+ local app on localhost:3000 is not StemDeck on localhost:8000. When the Host
+ carries no port, a proxy terminated the scheme and the default port applies
+ on the far side, so only the name is compared. An origin whose host is in
+ STEMDECK_ALLOWED_HOSTS matches by name, since proxies differ in what they
+ forward."""
+ try:
+ parts = urlsplit(origin)
+ o_host = (parts.hostname or "").lower().rstrip(".")
+ o_port = parts.port or _default_port(parts.scheme.lower())
+ except ValueError:
+ return False
+ if not o_host or parts.scheme.lower() not in ("http", "https"):
+ return False
+ extra = frozenset(extra_hosts)
+ if o_host in extra and "*" not in extra:
+ return True
+ for candidate in (_header(headers, "host"), _forwarded_host(headers)):
+ if not candidate:
+ continue
+ h_host, h_port = split_host(candidate)
+ if h_host == o_host and (h_port is None or h_port == o_port):
+ return True
+ return False
+
+
+def _is_json(content_type: str) -> bool:
+ media = content_type.split(";", 1)[0].strip().lower()
+ return media == "application/json" or (
+ media.startswith("application/") and media.endswith("+json")
+ )
+
+
+def _has_body(headers: Mapping[str, str]) -> bool:
+ declared = _header(headers, "content-length")
+ if declared:
+ return not (declared.isdigit() and int(declared) == 0)
+ return "chunked" in _header(headers, "transfer-encoding").lower()
+
+
+def _content_type_ok(method: str, path: str, headers: Mapping[str, str]) -> bool:
+ if not path.startswith("/api/") and path != "/api":
+ return True
+ if not _has_body(headers):
+ return True
+ ctype = _header(headers, "content-type")
+ if not ctype:
+ # No type at all is what curl --data-binary, Python clients and the
+ # test suite send, and FastAPI itself reads it as JSON. A browser sends
+ # it too (a no-cors fetch of an untyped Blob), but never without an
+ # Origin when cross-site, so check 1 has already refused that one.
+ return True
+ if _is_json(ctype):
+ return True
+ media = ctype.split(";", 1)[0].strip().lower()
+ for route_method, pattern, wanted in _RAW_BODY_ROUTES:
+ if method == route_method and pattern.match(path):
+ return wanted is None or media == wanted
+ return False
+
+
+def check_request(
+ method: str,
+ path: str,
+ headers: Mapping[str, str],
+ *,
+ allow_network: bool,
+ desktop: bool,
+ extra_hosts: Iterable[str],
+ local_ips: Iterable[str],
+) -> Refusal | None:
+ """None when the request may proceed, else why it may not.
+
+ `headers` is case-insensitive (Starlette's Headers). Pure: every input that
+ can change at runtime is passed in, so tests need no monkeypatching."""
+ method = method.upper()
+ extra = tuple(extra_hosts)
+
+ # 2. Host, for every method: a rebinding attack reads with GET.
+ host_header = _header(headers, "host")
+ if host_header:
+ host, _ = split_host(host_header)
+ if not host_allowed(
+ host, allow_network=allow_network, extra_hosts=extra, local_ips=local_ips
+ ):
+ return Refusal(400, REFUSED_HOST, f"host {host!r} not allowed")
+
+ if method not in UNSAFE_METHODS:
+ return None
+
+ # 1. Cross-site writes.
+ fetch_site = _header(headers, "sec-fetch-site").lower()
+ if fetch_site == "cross-site":
+ return Refusal(403, REFUSED_CROSS_SITE, "sec-fetch-site cross-site")
+ if fetch_site != "same-origin":
+ origin = _header(headers, "origin")
+ if origin:
+ if origin.lower() == "null":
+ return Refusal(403, REFUSED_CROSS_SITE, "opaque origin")
+ tauri = desktop and origin.lower() in TAURI_ORIGINS
+ if not tauri and not origin_matches(origin, headers, extra):
+ return Refusal(403, REFUSED_CROSS_SITE, f"origin {origin!r} not this server")
+
+ # 3. Bodies are JSON.
+ if not _content_type_ok(method, path, headers):
+ return Refusal(415, REFUSED_CONTENT_TYPE, "non-JSON body")
+ return None
diff --git a/app/core/device_reason.py b/app/core/device_reason.py
new file mode 100644
index 00000000..cbb342c4
--- /dev/null
+++ b/app/core/device_reason.py
@@ -0,0 +1,238 @@
+"""Why separation runs on the CPU on a machine that has an NVIDIA GPU (#247).
+
+Settings used to show only the resolved device, so a CPU-only torch, a CUDA
+torch that cannot start and a CUDA install that failed in setup all read as
+"currently: cpu". This derives one code from the facts the backend can see
+(nvidia-smi, the torch build, whether CUDA starts) and the outcome the desktop
+shell's setup persisted in config.json, which knows what it installed.
+
+A code is given only when the facts agree on it. A machine with no NVIDIA GPU
+gets none: the CPU is the expected device there.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+from app.core.config import (
+ DATA_DIR,
+ NVIDIA_SMI_TTL_SEC,
+ TIMEOUT_NVIDIA_SMI,
+ available_torch_devices,
+ torch_load_error,
+)
+
+logger = logging.getLogger("stemdeck.device_reason")
+
+# Written by the desktop shell's GPU setup step (persist_torch_device in
+# desktop/src-tauri/src/main.rs). Absent on a source, Docker or Unraid run.
+_config_path = DATA_DIR / "config.json"
+
+# Setup outcomes that explain a CPU torch on a machine with an NVIDIA GPU. A
+# failed restore of the CPU torch adds "-cpu-restore-failed"; the cause is the
+# part before it, and a torch that then cannot load has its own message.
+_SETUP_REASONS = frozenset(
+ {
+ "cuda-install-failed",
+ "cuda-verify-failed",
+ "cuda-unsupported-gpu",
+ "no-gpu-detected",
+ "cpu-only-package",
+ }
+)
+# Every value the shell writes. Anything else in config.json is reported as
+# "other", so the diagnostics line only ever carries a known word.
+_SETUP_VALUES = _SETUP_REASONS | {"verified", "mps", "mps-unavailable"}
+
+# Drivers before 616 print "CUDA Version: 12.6"; 616.64 prints
+# "CUDA UMD Version: 13.4" in the same header. "N/A" is no version. The twin
+# of parse_cuda_version in desktop/src-tauri/src/main.rs: keep the two one
+# rule, and both tests on tests/fixtures/nvidia_smi_headers.json (#1012).
+_CUDA_HEADER = re.compile(r"CUDA (?:UMD )?Version:\s*([0-9]+\.[0-9]+)")
+
+_lock = threading.Lock()
+# Held while nvidia-smi runs, so requests that arrive meanwhile wait for its
+# answer instead of each starting their own.
+_probe_lock = threading.Lock()
+_gpu: dict[str, str | None] | None = None
+_gpu_at: float | None = None
+
+
+def reset_state() -> None:
+ """Forget the cached nvidia-smi answer (tests)."""
+ global _gpu, _gpu_at
+ with _lock:
+ _gpu, _gpu_at = None, None
+
+
+def _nvidia_smi_path() -> str | None:
+ found = shutil.which("nvidia-smi")
+ if found or sys.platform != "win32":
+ return found
+ # The desktop shell starts this backend with a PATH that may not hold
+ # System32, and DCH drivers can ship nvidia-smi only in the DriverStore.
+ # The same places nvidia_smi_exe in the shell looks (#247).
+ system32 = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32"
+ if (system32 / "nvidia-smi.exe").is_file():
+ return str(system32 / "nvidia-smi.exe")
+ try:
+ hits = list((system32 / "DriverStore" / "FileRepository").glob("nv*/nvidia-smi.exe"))
+ return str(max(hits, key=lambda p: p.stat().st_mtime)) if hits else None
+ except OSError as exc:
+ logger.warning("could not search the DriverStore for nvidia-smi: %s", exc)
+ return None
+
+
+def _run_smi(smi: str, *args: str) -> str | None:
+ try:
+ out = subprocess.run( # noqa: S603 -- fixed args, the driver's own tool
+ [smi, *args],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_NVIDIA_SMI,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ logger.warning("nvidia-smi %s failed: %s", " ".join(args), exc)
+ return None
+ return out.stdout if out.returncode == 0 else None
+
+
+def _query_nvidia_gpu() -> dict[str, str | None] | None:
+ smi = _nvidia_smi_path()
+ if smi is None:
+ return None
+ listing = _run_smi(smi, "--query-gpu=name,driver_version", "--format=csv,noheader")
+ if not listing or not listing.strip():
+ return None
+ # One line per GPU; the first is the one torch would use as cuda:0.
+ name, _, driver = listing.strip().splitlines()[0].partition(",")
+ header = _run_smi(smi) or ""
+ m = _CUDA_HEADER.search(header)
+ return {
+ "name": name.strip(),
+ "driver": driver.strip() or None,
+ "cuda": m.group(1) if m else None,
+ }
+
+
+def _fresh() -> bool:
+ return _gpu_at is not None and time.monotonic() - _gpu_at < NVIDIA_SMI_TTL_SEC
+
+
+def refresh_nvidia_gpu() -> dict[str, str | None] | None:
+ """Ask nvidia-smi now unless an answer is fresh, and keep it. Started at
+ server start (app/main.py lifespan), so Settings never waits for the first
+ answer; a GPU waking up can take seconds."""
+ global _gpu, _gpu_at
+ with _probe_lock:
+ with _lock:
+ if _fresh():
+ return _gpu
+ gpu = _query_nvidia_gpu()
+ with _lock:
+ _gpu, _gpu_at = gpu, time.monotonic()
+ return gpu
+
+
+def nvidia_gpu() -> dict[str, str | None] | None:
+ """{"name", "driver", "cuda" (the newest CUDA the driver runs)} of the first
+ NVIDIA GPU, or None when there is none or nvidia-smi cannot say.
+
+ Waits only for the very first answer. An expired one is still returned,
+ and a new one is asked for in the background."""
+ with _lock:
+ known, fresh, gpu = _gpu_at is not None, _fresh(), _gpu
+ if not known:
+ return refresh_nvidia_gpu()
+ if not fresh and not _probe_lock.locked():
+ threading.Thread(target=refresh_nvidia_gpu, name="nvidia-smi", daemon=True).start()
+ return gpu
+
+
+def _torch_build() -> tuple[str, str | None, str | None] | None:
+ """(torch version, CUDA version it was built for, ROCm (HIP) version it
+ was built for), or None when torch cannot be imported. A CPU build has
+ neither."""
+ try:
+ import torch
+ except Exception: # noqa: BLE001 -- the device probe reports why (#730)
+ return None
+ return str(torch.__version__), torch.version.cuda, torch.version.hip
+
+
+def _setup_reason() -> str | None:
+ try:
+ reason = json.loads(_config_path.read_text(encoding="utf-8")).get("torchDeviceReason")
+ except (OSError, ValueError, AttributeError):
+ return None
+ return reason if isinstance(reason, str) else None
+
+
+def _major(version: str | None) -> int | None:
+ head = (version or "").split(".")[0]
+ return int(head) if head.isdigit() else None
+
+
+def cpu_reason() -> str | None:
+ """Why the GPU is not used, as one code, or None when there is nothing to
+ explain (the GPU is in use, or the machine has none)."""
+ devices = available_torch_devices()
+ if torch_load_error() is not None:
+ return None
+ setup = _setup_reason()
+ if setup == "mps-unavailable" and "mps" not in devices:
+ return "mps-unavailable"
+ if "cuda" in devices:
+ return None
+ build = _torch_build()
+ gpu = nvidia_gpu()
+ if build is None or gpu is None:
+ return None
+ _version, torch_cuda, torch_hip = build
+ if torch_hip is not None:
+ # The AMD build (#825): none of the NVIDIA sentences describes it.
+ return None
+ if torch_cuda is None:
+ # Setup's outcome describes the CPU torch it left in place, so it is
+ # trusted only while that is still the torch installed. With a GPU
+ # visible here, "no-gpu-detected" is setup's own miss (its probe found
+ # no nvidia-smi), which is why it installed no GPU support (#247).
+ cause = (setup or "").removesuffix("-cpu-restore-failed")
+ return cause if cause in _SETUP_REASONS else "cpu-torch-build"
+ driver_major, torch_major = _major(gpu["cuda"]), _major(torch_cuda)
+ if driver_major is not None and torch_major is not None and driver_major < torch_major:
+ return "driver-too-old"
+ return "cuda-unavailable"
+
+
+def device_diagnostics(why: str | None) -> str:
+ """The facts behind `why` (what cpu_reason() returned) on one line, for a
+ bug report."""
+ gpu = nvidia_gpu() or {}
+ version, torch_cuda, _hip = _torch_build() or (None, None, None)
+ setup = _setup_reason()
+ if setup is not None and setup.removesuffix("-cpu-restore-failed") not in _SETUP_VALUES:
+ setup = "other"
+ facts = {
+ "gpu": gpu.get("name"),
+ "driver": gpu.get("driver"),
+ "driver_cuda": gpu.get("cuda"),
+ "torch": version,
+ "torch_cuda": torch_cuda,
+ "cuda_available": "yes" if "cuda" in available_torch_devices() else "no",
+ "setup": setup,
+ "why": why,
+ }
+ return ", ".join(f"{key}={value or 'none'}" for key, value in facts.items())
diff --git a/app/core/model_cache.py b/app/core/model_cache.py
index 0de34568..14bca045 100644
--- a/app/core/model_cache.py
+++ b/app/core/model_cache.py
@@ -28,6 +28,7 @@
import logging
import shutil
+import traceback
from collections.abc import Callable, Iterable
from pathlib import Path
from typing import TypeVar
@@ -91,6 +92,11 @@ def load_or_heal(load: Callable[[], T], stale: Callable[[], Iterable[Path]]) ->
fresh = [p for p in paths if p not in _healed]
if not fresh:
raise
+ # audio-separator reads its indexes with json.load(open(...)) and
+ # never closes the file, so the one that failed is still open in the
+ # traceback's frames, and Windows will not delete an open file.
+ # Clearing the frames' locals closes it; the traceback itself stays.
+ traceback.clear_frames(first.__traceback__)
removed = [p for p in fresh if _remove(p)]
_healed.update(fresh)
if not removed:
@@ -125,10 +131,16 @@ def vocal_split_artifacts(models_dir: Path, model_file: str) -> list[Path]:
they are the names audio-separator actually writes: a single
"model_data.json" is not one of them, so listing that instead heals the
checkpoint and leaves the index that produced the error in place.
+
+ download_checks.json, the list of models audio-separator offers, is read
+ before any of them, and is written in place like the rest: a setup ended
+ mid-fetch (the wizard's Skip, #405) leaves it half written, and json.load
+ fails on it every time after.
"""
root = models_dir / "audio-separator"
return [
root / model_file,
root / "vr_model_data.json",
root / "mdx_model_data.json",
+ root / "download_checks.json",
]
diff --git a/app/core/model_download.py b/app/core/model_download.py
new file mode 100644
index 00000000..2bffd82f
--- /dev/null
+++ b/app/core/model_download.py
@@ -0,0 +1,130 @@
+"""Bounded model downloads (#125).
+
+torch.hub fetches a checkpoint with urlopen() and no timeout, so a network that
+opens the connection and then sends nothing blocks the caller forever, at 0%
+CPU, with nothing on screen. bounded_downloads() gives every torch.hub download
+inside it a read timeout and a few tries, and turns the last failure into a
+ModelDownloadError that names the file's address and the folder a person can
+put it in by hand. torch.hub only downloads a file that is not in that folder
+yet, so a file put there is used as it is.
+
+The timeout is the socket default, which every socket created inside the block
+inherits, so it is set for the block only and put back after it: the worker's
+pipes and anything the server does are never touched by it.
+"""
+
+from __future__ import annotations
+
+import http.client
+import os
+import socket
+import ssl
+import sys
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from time import sleep
+from urllib.error import HTTPError, URLError
+
+from app.core.config import MODEL_DOWNLOAD_BACKOFF
+
+# torch.hub's own words for a file whose SHA-256 does not start with the prefix
+# in its name (download_url_to_file). A proxy or captive portal that answers
+# with a page of its own ends here, so it is a failed download like the others.
+_HASH_MISMATCH = "invalid hash value"
+
+
+class ModelDownloadError(RuntimeError):
+ """A model file could not be downloaded after every try."""
+
+ def __init__(self, url: str, folder: str, reason: str) -> None:
+ # "model download failed:" is what errors.classify_failure matches. The
+ # reason goes last: the job's error_detail keeps 200 characters, and a
+ # long one (a proxy's TLS error) would otherwise cut off the folder.
+ super().__init__(f"model download failed: {url}; put the file in {folder} ({reason})")
+
+
+def _reason(exc: BaseException, read_timeout: int) -> str:
+ if isinstance(exc, HTTPError):
+ return f"HTTP {exc.code}"
+ if isinstance(exc, URLError) and isinstance(exc.reason, BaseException):
+ exc = exc.reason
+ if isinstance(exc, TimeoutError):
+ return f"no data for {read_timeout} s"
+ if isinstance(exc, socket.gaierror):
+ return "DNS lookup failed"
+ if isinstance(exc, ConnectionRefusedError):
+ return "connection refused"
+ if isinstance(exc, (ConnectionError, http.client.IncompleteRead)):
+ return "connection closed early"
+ if isinstance(exc, RuntimeError):
+ return "file did not match its checksum"
+ return str(exc) or type(exc).__name__
+
+
+def _is_download_failure(exc: Exception) -> bool:
+ # A full disk or a folder that cannot be written is an OSError too, and is
+ # not a network problem a retry or another network could solve. A TLS
+ # connection cut mid-file raises a bare ssl.SSLError: URLError only wraps
+ # what goes wrong while connecting.
+ if isinstance(exc, RuntimeError):
+ return str(exc).startswith(_HASH_MISMATCH)
+ return isinstance(
+ exc, (URLError, TimeoutError, ConnectionError, ssl.SSLError, http.client.HTTPException)
+ )
+
+
+def fetch_with_retry(
+ fn: Callable[[], None], *, url: str, folder: str, attempts: int, read_timeout: int
+) -> None:
+ """Call `fn` until it returns, at most `attempts` times, pausing between
+ tries. A download failure on the last try raises ModelDownloadError; any
+ other error propagates as it is, at once."""
+ for attempt in range(1, attempts + 1):
+ try:
+ fn()
+ return
+ except Exception as exc:
+ if not _is_download_failure(exc):
+ raise
+ reason = _reason(exc, read_timeout)
+ if attempt == attempts:
+ raise ModelDownloadError(url, folder, reason) from exc
+ delay = MODEL_DOWNLOAD_BACKOFF[min(attempt, len(MODEL_DOWNLOAD_BACKOFF)) - 1]
+ # On stderr, which the separation worker's parent reads: the line
+ # shows in the failure's evidence, and it resets the parent's stall
+ # timer, so the stall limit has to outlast one try, not all of them.
+ sys.stderr.write(
+ f"model download try {attempt} of {attempts} failed ({reason}), "
+ f"trying again in {delay} s\n"
+ )
+ sys.stderr.flush()
+ sleep(delay)
+
+
+@contextmanager
+def bounded_downloads(read_timeout: int, attempts: int) -> Iterator[None]:
+ """Inside the block, every torch.hub file download gives up after
+ `read_timeout` seconds without a byte and is tried `attempts` times."""
+ import torch.hub
+
+ download = torch.hub.download_url_to_file
+
+ def bounded(url: str, dst: str, hash_prefix: str | None = None, progress: bool = True) -> None:
+ fetch_with_retry(
+ lambda: download(url, dst, hash_prefix, progress),
+ url=url,
+ folder=os.path.dirname(os.path.abspath(dst)),
+ attempts=attempts,
+ read_timeout=read_timeout,
+ )
+
+ previous = socket.getdefaulttimeout()
+ socket.setdefaulttimeout(read_timeout)
+ # load_state_dict_from_url, which demucs calls, looks this name up in
+ # torch.hub when it runs, so the replacement is what it calls.
+ torch.hub.download_url_to_file = bounded
+ try:
+ yield
+ finally:
+ torch.hub.download_url_to_file = download
+ socket.setdefaulttimeout(previous)
diff --git a/app/core/models.py b/app/core/models.py
index 4e0a1b85..5a8833c8 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -8,12 +8,19 @@
from typing import Any, Literal
from app.core.config import AUDIO_TAG_LYRICS_MAX_CHARS, AUDIO_TAG_MAX_CHARS
+from app.core.versions import VERSION_ID_RE, active_of, clean_versions, versions_of
+from app.core.vocal_split_outcome import INTERRUPTED, message_for
class JobCancelled(Exception):
"""Raised inside a pipeline stage when the job's cancel flag is set."""
+class JobBusy(Exception):
+ """A finished song is being worked on (Job.post_hoc_running), so its folder
+ must not be deleted, moved or reset away. Answered as 409 "job_busy"."""
+
+
JobStatus = Literal[
"queued",
"downloading",
@@ -222,6 +229,14 @@ def settle_cancelled(job: Job) -> bool:
return True
+# Bumped when the loudness card's measurement changes, so songs analysed with
+# an older one are measured again. 1 (no field) was the mono 22 kHz, first 180 s
+# reading; 2 is BS.1770-4 on the whole stereo mix (analyze.measure_loudness).
+LOUDNESS_VERSION = 2
+# What a song measured again was read from (Job.loudness_remeasured).
+LOUDNESS_BASES = ("source", "stems")
+
+
@dataclass
class Job:
id: str
@@ -235,9 +250,22 @@ class Job:
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)
+ lufs: float | None = None # ITU-R BS.1770-4 integrated loudness of the stereo mix (LUFS)
+ peak_db: float | None = None # true peak in dBTP
+ dynamic_range: float | None = None # peak_db - lufs: peak-to-loudness ratio (dB), not LRA
+ # Which loudness measurement wrote the three fields above. Songs analysed
+ # before 2 hold a mono 22 kHz first-180-s reading, about 4 LU too low; they
+ # are measured again the first time they are opened (jobs._upgrade_loudness).
+ loudness_version: int | None = None
+ # What an older song's numbers were measured again from (#923): "source"
+ # for its kept upload, "stems" for the sum of its stems (a link song keeps
+ # no source). None when the numbers come from the song's own analysis.
+ # Persisted, since the measurement runs after an open and the next open,
+ # the first to show the new numbers, is often after a restart. The page
+ # tells the user once per song why the card's numbers moved.
+ # Here a string, in to_state() two keys: "loudness_remeasured" is a bool
+ # (a known basis is stored) and "loudness_basis" carries the string.
+ loudness_remeasured: str | None = None
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, kind?, start, end, color}]
@@ -247,9 +275,21 @@ class Job:
# submit, and the toggle is a per-import choice that clears itself: reading
# it late let a job lose a pass the user had asked and waited for.
auto_sections: bool = False
+ # Whether this job looks for lyrics (LRCLIB, the transcript, the retime),
+ # captured from the Detect lyrics setting when the job is created. None
+ # for a job made before the setting existed, which looks as it always did.
+ auto_lyrics: bool | None = None
sections_source: Literal["automatic", "manual"] | None = None
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
+ # Recorded takes, finished ones only, in creation order: the lanes the
+ # studio adds after the stems. Each is app.core.takes.take_entry's shape.
+ # The take itself (raw capture, sidecar, camera) stays in
+ # takes// beside the stems; this is the index the UI reads.
+ takes: list[dict[str, Any]] = field(default_factory=list)
+ # Recording tracks, sorted by order: each a mixer lane that plays its
+ # active take. Mirrors takes/tracks.json (app.core.takes).
+ tracks: list[dict[str, Any]] = 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
@@ -316,12 +356,44 @@ class Job:
# Wall-clock seconds per pipeline stage ({"download": 12.3, ...}); written
# to metadata.json and the one-line completion summary in the log.
stage_timings: dict[str, float] | None = None
+ # How this song's stems are kept on disk (#783): "wav" or "mp3", set when
+ # the job finishes from the Stem storage format setting of that moment,
+ # with the MP3 bitrate (kbps) or None. None for a song finished before the
+ # setting existed, whose stems are WAV. Readers do not branch on it: they
+ # resolve each file through app.core.stem_files, which looks at the disk.
+ stem_format: str | None = None
+ stem_mp3_kbps: int | None = None
# On-demand lead/backing vocal split (#275) -- a post-hoc action on an
# already-"done" job, not part of the main pipeline. "none" until the user
# asks for it; "error" leaves the job's base stems untouched (see
# app/pipeline/vocal_split.py) and is recorded in stems/vocal_split_error.txt,
# not job.error_detail, since the job itself did not fail.
vocal_split: Literal["none", "running", "done", "error"] = "none"
+ # Why the last split did not make its two stems, while vocal_split is
+ # "error": one of app.core.vocal_split_outcome.CODES, so the client can say
+ # what happened and what to do instead of showing one Vocals lane with no
+ # word about it (#832). None otherwise.
+ vocal_split_reason: str | None = None
+ # Whether Lead + Backing was chosen when the job was submitted (#848). Kept
+ # on the job rather than only in the page that asked, so a reload, a
+ # restart or a second window still asks for the split once the job is
+ # done. False for a record written before it existed.
+ vocal_split_wanted: bool = False
+ # Versions of the song (#804, app/core/versions.py): named choices of
+ # stems over the one set of stem files. None until one is made, created,
+ # renamed or chosen: the song then reads as its implicit default version.
+ versions: list[dict[str, Any]] | None = None
+ # The version that opens with the song, or None for the first one.
+ active_version: str | None = None
+ # How the stems were separated (#804), recorded when separation runs, so
+ # a later Extract fills missing stems with exactly the same settings and
+ # they add up to the song. None for a song separated before this existed.
+ separation_model: str | None = None
+ separation_quality: str | None = None
+ # Separating the stems a song does not have yet (POST .../fill-stems), a
+ # post-hoc action on a "done" job like vocal_split. "error" leaves every
+ # stem the song had as it was.
+ stem_fill: Literal["none", "running", "done", "error"] = "none"
# When the user put this job in the Trash, or None if they have not.
#
# Server-side on purpose. The Trash used to live only in the browser's
@@ -359,9 +431,10 @@ class Job:
# submission order after a restart; the position the UI shows is derived
# from the live deque. Old records default to 0, where created_at decides.
queue_position: int = 0
- # How many times a restart has put this job back in the queue. Persisted,
- # so a job that reliably kills the process is failed rather than retried on
- # every start. Old records without the field default to 0 via from_record.
+ # How many times the queue has started this job, counted at the start and
+ # never by a restart. Persisted, so a job that reliably kills the process
+ # is failed rather than retried on every start. Old records without the
+ # field default to 0 via from_record.
resume_attempts: int = 0
# Wall-clock timestamps for metadata-based sweep -- more predictable
# than directory mtime, which can be touched by unrelated FS events.
@@ -383,12 +456,20 @@ def to_state(self) -> dict[str, Any]:
"lufs": self.lufs,
"peak_db": self.peak_db,
"dynamic_range": self.dynamic_range,
+ "loudness_version": self.loudness_version,
+ "loudness_remeasured": self.loudness_remeasured in LOUDNESS_BASES,
+ "loudness_basis": self.loudness_remeasured
+ if self.loudness_remeasured in LOUDNESS_BASES
+ else None,
"tempo_stability": self.tempo_stability,
"stem_presence": self.stem_presence,
"sections": self.sections,
"sections_source": self.sections_source,
+ "auto_lyrics": self.auto_lyrics,
"tags": self.tags,
"stems": self.stems,
+ "takes": self.takes,
+ "tracks": self.tracks,
"selected_stems": self.selected_stems,
"mix_url": self.mix_url,
"source_url": self.source_url,
@@ -405,12 +486,26 @@ def to_state(self) -> dict[str, Any]:
"compute_device": self.compute_device,
"gpu_fallback": self.gpu_fallback,
"stage_timings": self.stage_timings,
+ "stem_format": self.stem_format,
+ "stem_mp3_kbps": self.stem_mp3_kbps,
"vocal_split": self.vocal_split,
+ "vocal_split_reason": self.vocal_split_reason,
+ # A fixed sentence per code, never the worker's own words.
+ "vocal_split_message": message_for(self.vocal_split_reason),
+ "vocal_split_wanted": self.vocal_split_wanted,
+ "versions": versions_of(self),
+ "active_version": active_of(self),
+ "stem_fill": self.stem_fill,
"trashed_at": self.trashed_at,
"favorite": self.favorite,
"created_at": self.created_at,
}
+ def post_hoc_running(self) -> bool:
+ """A stem fill or a vocal split is writing into this song's folder.
+ The queue statuses do not show it: the song stays "done" meanwhile."""
+ return self.stem_fill == "running" or self.vocal_split == "running"
+
def to_queue_state(self) -> dict[str, Any]:
"""The compact record the queue view needs. Deliberately not to_state():
the queue stream carries every waiting job several times a second, and
@@ -444,6 +539,21 @@ def from_record(cls, data: dict[str, Any]) -> Job:
job.audio_tags = clean_audio_tags(job.audio_tags)
job.cancel_requested = False
job.stop_requested = False
+ # Saved while a split ran, so the process ended before it did. Left
+ # "running", every later attempt was refused as already running.
+ if job.vocal_split == "running":
+ job.vocal_split = "error"
+ job.vocal_split_reason = INTERRUPTED
+ # Saved as "Splitting lead/backing vocals...", which the song's row
+ # would keep showing. The split's own failure path says "Done".
+ job.stage_message = "Done"
+ # Never fails the song: a damaged versions list loses only the
+ # entries that are not a version (app/core/versions.py).
+ job.versions = clean_versions(job.versions)
+ if not isinstance(job.active_version, str) or not VERSION_ID_RE.match(job.active_version):
+ job.active_version = None
+ if job.stem_fill == "running":
+ job.stem_fill = "error"
return job
diff --git a/app/core/redact.py b/app/core/redact.py
index 514baa5f..02a176b4 100644
--- a/app/core/redact.py
+++ b/app/core/redact.py
@@ -3,7 +3,7 @@
The notification centre's "Report on GitHub"/"Report on Discord" flow and its
opt-in "include recent logs" button both end with this text pasted somewhere
-public. Three things in particular have no business being there:
+public. Four things in particular have no business being there:
- The reporter's home directory. On Windows the Python install path alone
(`C:\\Users\\\\...`) carries the OS username.
@@ -18,10 +18,21 @@
network access is on, so uvicorn's access log (captured into
backend.log on desktop) can carry another device's address on the
reporter's home network, not just their own.
+ - A line of the cookies file. yt-dlp writes a line it cannot parse to
+ stderr, which is backend.log on desktop, and into the job's error; that
+ line is the user's YouTube session.
+
+Paths are matched in any spelling a log carries them in (either slash,
+doubled backslashes from repr(), any case) and replaced by a placeholder
+that says which folder it was, so the rest of the path stays readable. The
+user name is only removed as a whole path segment: replacing the bare word
+would rewrite every word that contains it, and a short or common name would
+shred the whole log.
Every place that can hand text to that report flow -- the per-job failure
-evidence (runner.py) and the Settings -> Logs viewer (main.py) -- redacts
-through this one function rather than each carrying its own copy.
+evidence (runner.py), the Settings -> Logs viewer and the Export logs zip
+(main.py) -- redacts through this one function rather than each carrying
+its own copy.
"""
from __future__ import annotations
@@ -29,6 +40,8 @@
import re
from pathlib import Path
+from app.core.config import CACHE_DIR, DATA_DIR, JOBS_DIR, MODELS_DIR
+
# Mirrors app/pipeline/download.py's _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS. Kept
# as a literal copy rather than importing it: this module is used from
# main.py's request path, and pipeline.download pulls in yt_dlp, which is not
@@ -46,13 +59,82 @@
# instead of catching the rare home IPv6 address.
_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
+# yt-dlp's cookies.py: "WARNING: skipping cookie file entry due to {e}: {line!r}",
+# and http.cookiejar's "invalid Netscape format cookies file {name!r}: {line!r}"
+# for a line that passes yt-dlp's checks, which yt-dlp reports as an error.
+_COOKIE_LINE_RE = re.compile(
+ r"(skipping cookie file entry|invalid Netscape format cookies file)[^\r\n]*"
+)
+
+# Every path pattern starts on a plain character, with any lookbehind after it
+# and ignore-case scoped to the rest: a leading lookbehind or a global
+# ignore-case flag stops the scan skipping ahead, about 4x slower on a 5 MB log.
+_SEP = r"[\\/]+"
+# Not glued to a name before it: /data, but not the end of /srv/data.
+_AT_START = r"(?|:*?]*[^\\/\s\"'<>|:*?.]"
+# Anyone's home, not only this process's: another account, a log from another
+# machine, an 8.3 short name. The folder after Users or home is the user name.
+# The drive letter must not end a word, so https://users/... is not a drive.
+_WIN_HOME_RE = re.compile(
+ rf"[A-Za-z](? re.Pattern[str]:
+ """`path` in either slash, any case, with repr()'s doubled backslashes,
+ from a segment start to a segment end."""
+ head, *rest = re.split(r"[\\/]+", str(path))
+ # head is the drive (C:), or empty for a POSIX root or a UNC share.
+ if head:
+ start = rf"[{head[0].upper()}{head[0].lower()}]{_AT_START}{re.escape(head[1:])}{_SEP}"
+ else:
+ # Only the first separator of a run may start a match; otherwise
+ # every slash in a long run rescans the rest of it (quadratic).
+ start = r"[\\/](? str:
- """Strip the reporter's home directory, any YouTube/SoundCloud source
- URL, and any IPv4 address from `text`."""
- home = str(Path.home())
- if home and home not in (".", "/"):
- text = text.replace(home, "")
+ """Strip the app's folders, any user's home folder and name, network
+ shares, any YouTube/SoundCloud source URL, any IPv4 address and any
+ cookies file line from `text`."""
+ text = _COOKIE_LINE_RE.sub(r"\1 ", text)
+ home = Path.home()
+ folders = [
+ (JOBS_DIR, ""),
+ (CACHE_DIR, ""),
+ (MODELS_DIR, ""),
+ (DATA_DIR, ""),
+ (home, ""),
+ ]
+ # Longest first, so a folder inside another (jobs inside data, anything
+ # inside home) keeps its own name.
+ for folder, placeholder in sorted(folders, key=lambda f: len(str(f[0])), reverse=True):
+ if folder.name:
+ text = _path_re(folder).sub(placeholder, text)
+ text = _WIN_HOME_RE.sub("", text)
+ text = _POSIX_HOME_RE.sub("", text)
+ text = _UNC_RE.sub("", text)
+ # The home folder's name is the user's only inside a users folder; a
+ # service or container home (/root, /github/home) would otherwise turn
+ # every /home in the log into .
+ if home.parent.name.lower() in ("users", "home", "documents and settings"):
+ text = re.sub(rf"([\\/])(?i:{re.escape(home.name)}){_AT_END}", r"\1", text)
text = _SOURCE_URL_RE.sub("", text)
text = _IPV4_RE.sub("", text)
return text
diff --git a/app/core/registry.py b/app/core/registry.py
index 69cffe21..6d3cca4e 100644
--- a/app/core/registry.py
+++ b/app/core/registry.py
@@ -1,27 +1,68 @@
from __future__ import annotations
+import dataclasses
import json
import logging
+import os
+import re
import shutil
import subprocess
+import sys
import threading
import time
import uuid
+from collections.abc import Iterable, Iterator
+from contextlib import contextmanager
+from contextvars import ContextVar
from pathlib import Path
-
-from app.core.config import DEMUCS_MODEL, EXTRA_STEM_NAMES, JOB_ID_RE, LYRICS_FILE, STEM_NAMES
-from app.core.models import Job, clean_artist, clean_audio_tags, clean_identity, clean_work
+from typing import Any, Literal
+
+from app.core.config import (
+ DEMUCS_MODEL,
+ EXTRA_STEM_NAMES,
+ JOB_ID_RE,
+ LYRICS_FILE,
+ STEM_NAMES,
+ TAKE_ID_RE,
+ TAKES_DIRNAME,
+ TRACK_ID_RE,
+ UPLOAD_EXTS,
+)
+from app.core.models import (
+ FINISHED_STATUSES,
+ Job,
+ clean_artist,
+ clean_audio_tags,
+ clean_identity,
+ clean_work,
+)
+from app.core.settings import fsync_dir, quarantine_corrupt
+from app.core.stem_files import stem_exists, stem_names_on_disk, stem_path, stem_url
+from app.core.takes import reconcile_tracks, recover_takes
+from app.core.versions import clean_stems
logger = logging.getLogger("stemdeck.registry")
-REGISTRY_VERSION = 1
+REGISTRY_VERSION = 2
+
+Operation = Literal["pipeline", "fill", "split", "retime"]
_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] = {}
+# Active subprocesses keyed by job id and operation. Lets POST /cancel
+# terminate the running process from the API thread instead of waiting for the
+# pipeline thread to notice the cancel flag. By operation too, because one song
+# can have several waiting (a fill, a split, a lyrics timing) while one of them
+# runs: keyed by id alone, cancelling one that waited terminated the process of
+# the one running, the shared Demucs worker included.
+_procs: dict[tuple[str, Operation], subprocess.Popen] = {}
+# The operation the processes registered from here on belong to. Set by the
+# endpoints that work on a finished song (see operation()); asyncio.to_thread
+# carries it into the thread that registers them. Anything else is the job's
+# own pipeline run.
+_operation: ContextVar[Operation] = ContextVar("operation", default="pipeline")
_lock = threading.Lock()
+# Orders registry.json saves (see persist). Taken before _lock, never inside it.
+_write_lock = threading.Lock()
_REGISTRY_FILE = "registry.json"
_TERMINAL = {"done"}
# Jobs that were queued or mid-flight when the process died. They are persisted
@@ -50,6 +91,66 @@
# small.
_deleted: set[str] = set()
+# What the user chose for a song, or its import recorded, that nothing in its
+# folder can be derived from. registry.json alone used to hold these, so a
+# registry rebuilt from the folders reset every one of them: a binned song came
+# back, versions (and the mixes the page keys by their ids) were gone, and a
+# link song could no longer fill its missing stems. metadata.json keeps a copy,
+# written when the song finishes and by every request that changes one.
+SAVED_FIELDS = (
+ "favorite",
+ "trashed_at",
+ "versions",
+ "active_version",
+ "vocal_split_wanted",
+ "selected_stems",
+ "source_url",
+ "source_format",
+ "separation_model",
+ "separation_quality",
+ "auto_lyrics",
+ "created_at",
+)
+
+
+def saved_fields(job: Job) -> dict[str, Any]:
+ return {name: getattr(job, name) for name in SAVED_FIELDS}
+
+
+# Held by the requests that change a song's metadata.json (app/api/jobs.py)
+# and by the write that starts one, so neither replaces the other's change.
+metadata_lock = threading.RLock()
+
+
+def keep_added_time(job: Job, job_dir: Path) -> None:
+ """Start the song's metadata.json with the time it was added, when its
+ folder has none yet, so a song rebuilt from its folder keeps its place in
+ the library (#772). The folder's own time moves with every file written
+ into it. Never fails the caller: without it, the rebuild takes the
+ folder's time, as it always did."""
+ meta_path = job_dir / "metadata.json"
+ with metadata_lock:
+ try:
+ if meta_path.exists():
+ return
+ # Whole or not at all: a file that cannot be read counts as a
+ # finished run's, and would make a separation cut off part way a
+ # done song.
+ _write_atomic(meta_path, json.dumps({"created_at": job.created_at}, indent=2) + "\n")
+ except OSError:
+ logger.warning("could not write the add time of %s", job.id, exc_info=True)
+
+
+# Newer than 0.20.0, which drops them from every record it saves, and kept in
+# metadata.json: after a downgrade and back, that is their only copy.
+_BACK_FROM_METADATA = (
+ *SAVED_FIELDS,
+ "stem_format",
+ "stem_mp3_kbps",
+ "loudness_version",
+ "loudness_remeasured",
+)
+
def register(job: Job) -> Job:
with _lock:
@@ -82,6 +183,8 @@ def requeue_if_capacity(job: Job, job_dir: Path, max_pending: int) -> bool:
# lock; the job is not in the queue until the caller enqueues it.
job.status = "queued"
reset_for_rerun(job, job_dir)
+ # A new request: a crash in this run gets the one resume, whatever ran before.
+ job.resume_attempts = 0
return True
@@ -109,7 +212,8 @@ def get(job_id: str) -> Job | None:
def remove(job_id: str) -> None:
with _lock:
_jobs.pop(job_id, None)
- _procs.pop(job_id, None)
+ for key in [key for key in _procs if key[0] == job_id]:
+ del _procs[key]
def set_trashed(job_id: str, trashed: bool) -> Job | None:
@@ -151,6 +255,11 @@ def _migrate(data: dict) -> dict:
# v0 → v1: version field was absent; no structural change needed.
data["version"] = 1
version = 1
+ if version < 2:
+ # v1 → v2: nothing changes in this file. restore() copies each
+ # song's SAVED_FIELDS into its metadata.json once.
+ data["version"] = 2
+ version = 2
# Future migrations go here as `if version < N:` blocks.
return data
@@ -166,14 +275,20 @@ def mark_deleted(job_id: str) -> None:
_deleted.add(job_id)
+def deleted_ids() -> set[str]:
+ """A snapshot of the ids the user deleted (see mark_deleted)."""
+ with _lock:
+ return set(_deleted)
+
+
def _prune_deleted(jobs_dir: Path) -> None:
"""Forget deletion records whose directory is finally gone.
A record only has to outlive the directory it refers to, so this keeps the
set naturally bounded instead of growing for the life of the install.
- Caller must not hold _lock."""
+ Caller must not hold _lock. The disk checks run outside it."""
+ stale = {job_id for job_id in deleted_ids() if not (jobs_dir / job_id).exists()}
with _lock:
- stale = {job_id for job_id in _deleted if not (jobs_dir / job_id).exists()}
_deleted.difference_update(stale)
@@ -181,11 +296,14 @@ def persist(jobs_dir: Path) -> None:
"""Persist terminal jobs so completed library entries survive restarts.
Callers run on the pipeline thread, API threads, and the sweep loop
- concurrently, so the write+replace happens under the lock with a unique
- temp name per call (#281) -- a shared temp path let two writers collide,
- and on Windows os.replace over a file another writer holds open raises
- PermissionError. Best-effort like the settings store: a failed persist
- logs and returns rather than killing the caller."""
+ concurrently, so each save takes its snapshot and writes it under
+ _write_lock with a unique temp name per call (#281) -- a shared temp path
+ let two writers collide, and on Windows os.replace over a file another
+ writer holds open raises PermissionError. One lock for both keeps a later
+ snapshot from being overwritten by an earlier one. Only the snapshot takes
+ _lock: every get() waited on the fsync when the write did too. Blocking,
+ so async callers run it in a thread. Best-effort like the settings store:
+ a failed persist logs and returns rather than killing the caller."""
try:
jobs_dir.mkdir(parents=True, exist_ok=True)
except OSError:
@@ -193,67 +311,283 @@ def persist(jobs_dir: Path) -> None:
return
path = registry_path(jobs_dir)
_prune_deleted(jobs_dir)
- with _lock:
- records = [
- job.to_record()
- for job in sorted(_jobs.values(), key=lambda item: item.created_at)
- if job.status in _PERSISTED
- ]
+ with _write_lock:
+ with _lock:
+ records = [
+ job.to_record()
+ for job in sorted(_jobs.values(), key=lambda item: item.created_at)
+ if job.status in _PERSISTED
+ ]
+ deleted = sorted(_deleted)
payload = (
json.dumps(
{
"version": REGISTRY_VERSION,
"jobs": records,
- "deleted": sorted(_deleted),
+ "deleted": deleted,
},
indent=2,
)
+ "\n"
)
- tmp = jobs_dir / f".registry.{uuid.uuid4().hex}.tmp"
try:
- tmp.write_text(payload, encoding="utf-8")
- tmp.replace(path)
+ _write_atomic(path, payload)
except OSError:
logger.warning("could not persist registry to %s", path, exc_info=True)
- finally:
- tmp.unlink(missing_ok=True)
+
+
+def _write_atomic(path: Path, payload: str) -> None:
+ """Replace `path` with `payload` so that a crash at any moment leaves
+ either the old file or the new one, never a torn or empty one.
+
+ The bytes are flushed and fsynced before the rename: without that, a power
+ loss right after os.replace can leave the new name pointing at a file whose
+ data never reached the disk, which is how a registry.json turns up empty.
+ The temp name is unique per call so concurrent writers cannot collide on it
+ (#281). Raises OSError; the temp file is gone either way."""
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
+ fh.write(payload)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
+ fsync_dir(path.parent)
+
+
+def _read_registry(path: Path) -> dict | None:
+ """The registry file as a JSON object, or None when it is unusable.
+
+ An unusable file (empty, truncated, not JSON, not an object, unreadable) is
+ moved aside to registry.json.corrupt- before anything else happens:
+ restore() goes on to re-adopt the jobs on disk and persist, and that persist
+ used to write over the only copy of the favourites, the Trash, the queue
+ and the delete tombstones. Same quarantine as a corrupt settings.json."""
+ try:
+ data = json.loads(path.read_bytes().decode("utf-8"))
+ except FileNotFoundError:
+ return None
+ except (OSError, RecursionError, ValueError):
+ # RecursionError: nesting too deep for the parser, which used to stop
+ # the backend booting.
+ logger.warning("registry at %s is unreadable", path, exc_info=True)
+ quarantine_corrupt(path, "registry")
+ return None
+ if not isinstance(data, dict):
+ logger.warning("registry at %s is not a JSON object", path)
+ quarantine_corrupt(path, "registry")
+ return None
+ return data
+
+
+def _keep_bad_records(path: Path, records: list, why: str) -> bool:
+ """Save the raw JSON of records the load skipped or repaired, beside the
+ registry as registry.json.bad-, so dropping them is never silent.
+ True when they were kept: only then does restore() rewrite registry.json
+ without them for that reason, which stops the next start setting them
+ aside again."""
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ target = path.with_name(f"{path.name}.bad-{stamp}-{uuid.uuid4().hex[:6]}")
+ try:
+ payload = json.dumps({"why": why, "records": records}, indent=2) + "\n"
+ _write_atomic(target, payload)
+ logger.warning("kept %d registry record(s) aside in %s: %s", len(records), target, why)
+ except (OSError, TypeError, ValueError):
+ logger.warning("could not keep bad registry records aside", exc_info=True)
+ return False
+ return True
+
+
+# A take or track entry without its id stops them being reconciled with the
+# disk on every start. Reset, the list is filled again from the take folders
+# and tracks.json (_sync_tracks).
+_ENTRY_IDS = {"takes": TAKE_ID_RE, "tracks": TRACK_ID_RE}
+
+
+def _annotation_types(annotation: object) -> tuple[type, ...] | None:
+ """The JSON types a Job field annotation allows, or None when it is not one
+ of the simple shapes the record uses (the field is then not checked).
+
+ The annotations are strings (from __future__ import annotations), such as
+ "float | None", "list[dict[str, Any]]" or "Literal['none', 'done']"."""
+ if not isinstance(annotation, str):
+ return None
+ allowed: list[type] = []
+ for part in annotation.split("|"):
+ base = part.strip().split("[", 1)[0]
+ if base == "None":
+ allowed.append(type(None))
+ elif base in ("str", "Literal", "JobStatus"):
+ allowed.append(str)
+ elif base in ("int", "float"):
+ allowed.extend((int, float))
+ elif base == "bool":
+ allowed.append(bool)
+ elif base == "list":
+ allowed.append(list)
+ elif base == "dict":
+ allowed.append(dict)
+ else:
+ return None
+ return tuple(allowed)
+
+
+def _repair_field_types(job: Job) -> list[str]:
+ """Reset every field whose value is the wrong JSON type to its default.
+
+ Job.from_record copies values as they are, so a record with a string where
+ a number belongs loaded fine and failed later, somewhere unrelated: sorting
+ by created_at in persist() raised, and from then on no save succeeded.
+ Returns the names of the fields it reset."""
+ repaired: list[str] = []
+ for f in dataclasses.fields(Job):
+ if f.name == "id":
+ continue
+ allowed = _annotation_types(f.type)
+ if allowed is None:
+ continue
+ value = getattr(job, f.name)
+ ok = isinstance(value, allowed)
+ if ok and isinstance(value, bool) and bool not in allowed:
+ ok = False # bool is an int subclass; True is not a number here
+ elif ok and isinstance(value, (int, float)) and not isinstance(value, bool):
+ # NaN and infinity cannot be sent as JSON, and an int too big for a
+ # float made the whole library list fail (OverflowError).
+ ok = abs(value) <= sys.float_info.max
+ if f.name == "created_at":
+ # No song was added before 1970. The auto-delete sweep would
+ # take one that says so as long expired.
+ ok = ok and value >= 0
+ elif ok and f.name in _ENTRY_IDS:
+ ok = all(
+ isinstance(entry, dict) and _ENTRY_IDS[f.name].match(str(entry.get("id")))
+ for entry in value
+ )
+ if ok:
+ continue
+ if f.default is not dataclasses.MISSING:
+ setattr(job, f.name, f.default)
+ elif f.default_factory is not dataclasses.MISSING:
+ setattr(job, f.name, f.default_factory())
+ else:
+ continue
+ repaired.append(f.name)
+ return repaired
def restore(jobs_dir: Path) -> None:
- """Load persisted jobs and recover completed orphan jobs from disk."""
+ """Load persisted jobs and recover orphan jobs from disk: finished ones
+ from their stems, stopped ones from the source they kept.
+
+ Nothing the user had is lost silently: a file that cannot be read as a
+ whole is moved aside (registry.json.corrupt-), and a record that
+ cannot be loaded as it was is skipped or repaired with its raw JSON kept in
+ registry.json.bad-. Every other record loads as before."""
jobs_dir.mkdir(parents=True, exist_ok=True)
path = registry_path(jobs_dir)
changed = False
- if path.is_file():
+ data = _read_registry(path) if path.is_file() else None
+ if data is not None:
try:
- data = _migrate(json.loads(path.read_text(encoding="utf-8")))
+ if not isinstance(data.get("version", 0), int):
+ # Read as the oldest, so every upgrade step runs and the
+ # records still load instead of the whole file moving aside.
+ data["version"] = 0
+ copy_saved = data.get("version", 0) < 2
+ data = _migrate(data)
recorded = data.get("deleted")
if isinstance(recorded, list):
with _lock:
- _deleted.update(str(job_id) for job_id in recorded)
+ _deleted.update(
+ job_id
+ for job_id in recorded
+ if isinstance(job_id, str) and JOB_ID_RE.match(job_id)
+ )
to_add = {}
resume: list[Job] = []
- for record in data.get("jobs", []):
- job = Job.from_record(record)
- if not JOB_ID_RE.match(job.id):
+ deleted = deleted_ids()
+ records = data.get("jobs", [])
+ if not isinstance(records, list):
+ changed = _keep_bad_records(path, [records], "the jobs list was not a list")
+ records = []
+ bad: list = []
+ # The fields each record really held, not a default filled in.
+ held: dict[str, set[str]] = {}
+ for record in records:
+ # One record at a time. A single bad one used to abort the whole
+ # load, and the persist after orphan recovery then wrote the
+ # survivors over everything else: favourites, the Trash, the
+ # queue and the delete tombstones.
+ rid = record.get("id") if isinstance(record, dict) else None
+ rid = rid if isinstance(rid, str) and JOB_ID_RE.match(rid) else None
+ # Only a well-formed id reaches the log. A second record for one
+ # song would otherwise replace the first unseen.
+ why = "no valid job id" if rid is None else "repeated" if rid in held else None
+ if why:
+ logger.warning("skipping registry record %s: %s", rid or "?", why)
+ bad.append(record)
continue
- # A stopped job may have no title yet: one trashed before its
- # download finished still has files and a place in the Trash.
- if (job.status in _TERMINAL and job.title) or job.status in _STOPPED:
- to_add[job.id] = job
- elif job.status in _RESUMABLE:
- recovered = _resume_or_recover(job, jobs_dir / job.id)
- if recovered is not None:
- to_add[recovered.id] = recovered
- # The bumped resume_attempts has to reach disk here. The
- # next persist is whenever the job finishes, so without
- # this a job that kills the process every time is read
- # back with the same count on every start and retried
- # forever -- the exact loop the cap exists to stop.
- changed = True
- if recovered.status in _RESUMABLE:
- resume.append(recovered)
+ try:
+ job = Job.from_record(record)
+ repaired = _repair_field_types(job)
+ except Exception as exc:
+ logger.warning("skipping registry record %s: %s", rid, type(exc).__name__)
+ bad.append(record)
+ continue
+ if repaired:
+ logger.warning(
+ "registry record %s had wrong values in %s; reset to defaults",
+ job.id,
+ ", ".join(repaired),
+ )
+ bad.append(record)
+ if job.id in deleted:
+ # Deleted while it was still stopping, and the process
+ # ended before the stop settled (#748): finish the delete.
+ shutil.rmtree(jobs_dir / job.id, ignore_errors=True)
+ changed = True
+ continue
+ held[job.id] = set(record) - set(repaired)
+ try:
+ # A stopped job may have no title yet: one trashed before
+ # its download finished still has files and a Trash place.
+ if (job.status in _TERMINAL and job.title) or job.status in _STOPPED:
+ to_add[job.id] = job
+ elif job.status in _RESUMABLE:
+ recovered = _resume_or_recover(job, jobs_dir / job.id)
+ if recovered is not None:
+ to_add[recovered.id] = recovered
+ # Saved as decided here: queued again, done, or an
+ # error that leaves the file.
+ changed = True
+ if recovered.status in _RESUMABLE:
+ resume.append(recovered)
+ except Exception:
+ logger.warning("could not restore registry record %s", job.id, exc_info=True)
+ bad.append(record)
+ if bad and _keep_bad_records(
+ path, bad, "records that could not be loaded as they were"
+ ):
+ changed = True
+ for job in to_add.values():
+ if _sync_tracks(job, jobs_dir / job.id):
+ changed = True
+ if job.status in _TERMINAL:
+ _backfill_stems_complete(jobs_dir / job.id)
+ if copy_saved:
+ # Songs finished before metadata.json kept a copy get one now,
+ # or a registry lost later would still reset all of them. A
+ # record 0.20.0 saved takes back from it what 0.20.0 dropped.
+ copied = sum(
+ _copy_saved_fields(job, jobs_dir, held.get(job.id, set()))
+ for job in to_add.values()
+ if job.status in _TERMINAL
+ )
+ if copied:
+ logger.info("copied saved fields into %d song folder(s)", copied)
+ changed = True
with _lock:
_jobs.update(to_add)
# The user's order first, then oldest first. queue_position is
@@ -267,11 +601,12 @@ def restore(jobs_dir: Path) -> None:
except Exception:
# Broad on purpose. restore() runs at import time, so anything that
# escapes here stops the backend booting with no way for a user to
- # recover short of deleting the file by hand. A registry.json that
- # is valid JSON but not an object (a top-level list, null, a bare
- # string) used to do exactly that: _migrate calls data.get() and
- # raises AttributeError, which the old tuple did not name (#520).
+ # recover short of deleting the file by hand (#520). Records are
+ # guarded one by one above, so reaching this is about the file as a
+ # whole: keep its bytes before the persist below can replace them.
logger.warning("failed to load registry from %s", path, exc_info=True)
+ if path.is_file():
+ quarantine_corrupt(path, "registry")
# Orphan recovery, and the persist that follows it, were outside the guard
# above -- an unreadable jobs_dir or a failed write was fatal at startup for
@@ -280,6 +615,9 @@ def restore(jobs_dir: Path) -> None:
with _lock:
known = set(_jobs)
deleted = set(_deleted)
+ rebuilt: list[str] = []
+ unsaved: list[str] = []
+ stopped: list[str] = []
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
@@ -287,27 +625,92 @@ def restore(jobs_dir: Path) -> None:
# The user deleted this and its files outlived the delete.
# Adopting it here is what brought songs back (#521).
continue
- recovered = _recover_done_job(job_dir)
- if recovered is not None:
+ try:
+ found = _rebuild_from_folder(job_dir)
+ except Exception:
+ # One folder that cannot be read costs that song, not every
+ # folder after it and the save that follows.
+ logger.warning("could not rebuild %s from its folder", job_dir.name, exc_info=True)
+ continue
+ if found is not None:
+ recovered, had_saved = found
+ _sync_tracks(recovered, job_dir)
with _lock:
_jobs[recovered.id] = recovered
changed = True
+ rebuilt.append(recovered.id)
+ if not had_saved:
+ unsaved.append(recovered.id)
+ elif (kept := _stopped_from_source(job_dir)) is not None:
+ _sync_tracks(kept, job_dir)
+ with _lock:
+ _jobs[kept.id] = kept
+ changed = True
+ stopped.append(kept.id)
+ if stopped:
+ more = f" and {len(stopped) - 20} more" if len(stopped) > 20 else ""
+ logger.warning(
+ "registry.json had no entry for %d song(s) that were never separated; "
+ "brought them back as stopped songs from the source they kept: %s%s",
+ len(stopped),
+ ", ".join(stopped[:20]),
+ more,
+ )
+ if rebuilt:
+ listed = ", ".join(unsaved[:20]) or "none"
+ if len(unsaved) > 20:
+ listed += f" and {len(unsaved) - 20} more"
+ logger.warning(
+ "registry.json had no entry for %d song(s); rebuilt them from their folders: "
+ "%d with their favourite, Trash, versions and source, %d without "
+ "(metadata.json older than those, or unreadable): %s",
+ len(rebuilt),
+ len(rebuilt) - len(unsaved),
+ len(unsaved),
+ listed,
+ )
if changed:
persist(jobs_dir)
except Exception:
logger.warning("failed to recover jobs from %s", jobs_dir, exc_info=True)
+def _copy_saved_fields(job: Job, jobs_dir: Path, held: set[str]) -> bool:
+ """Put the job's SAVED_FIELDS into its metadata.json, changing nothing else
+ in it. A field the registry record did not hold comes the other way: 0.20.0
+ drops versions and the newer fields from the registry, so after a
+ downgrade and back, metadata.json is their only copy. False when the song
+ has no usable metadata.json."""
+ meta_path = (jobs_dir / job.id / "metadata.json").resolve()
+ if not meta_path.is_relative_to(jobs_dir.resolve()):
+ return False
+ try:
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
+ if not isinstance(meta, dict):
+ return False
+ _read_meta_fields(job, meta, [n for n in _BACK_FROM_METADATA if n not in held])
+ for name, value in saved_fields(job).items():
+ if name in held or name not in meta:
+ meta[name] = value
+ _write_atomic(meta_path, json.dumps(meta, indent=2) + "\n")
+ except FileNotFoundError:
+ return False
+ except (OSError, RecursionError, ValueError):
+ logger.warning("could not copy saved fields into %s", meta_path, exc_info=True)
+ return False
+ return True
+
+
def _resume_or_recover(job: Job, job_dir: Path) -> Job | None:
"""Decide what a job that was in flight when the process died becomes.
- Three states of the same directory need telling apart. If the stems are all
- there, the crash landed between the last stem being written and the "done"
- persist, so the work is finished and re-running it would duplicate the
- library entry. Otherwise the job goes back in the queue from the top, after
- the partial demucs output is cleared so collect() cannot mistake it for
- results. A job that has already burned its resume is failed loudly rather
- than retried forever.
+ Three states of the same directory need telling apart. If the stems are
+ marked complete, the crash landed between the last stem being written and
+ the "done" persist, so the work is finished and re-running it would
+ duplicate the library entry. Otherwise the job goes back in the queue from
+ the top, after the partial demucs output is cleared so collect() cannot
+ mistake it for results. A job that has already burned its resume is failed
+ loudly rather than retried forever.
A job the user trashed before the restart is not resumed (#748). It came
back as a Paused row in the queue, offered to Start a song that was in the
@@ -315,9 +718,10 @@ def _resume_or_recover(job: Job, job_dir: Path) -> Job | None:
has, and its files stay until the Trash is emptied."""
recovered = _recover_done_job(job_dir)
if recovered is not None:
- # Finished after all: keep it where the user put it, or a song they
- # binned reappears in the library.
- recovered.trashed_at = job.trashed_at
+ # Finished after all: keep what the registry says the user chose (its
+ # Trash, its Lead + Backing still to be split), the newer source.
+ for name in SAVED_FIELDS:
+ setattr(recovered, name, getattr(job, name))
return recovered
if job.trashed_at is not None:
@@ -326,7 +730,9 @@ def _resume_or_recover(job: Job, job_dir: Path) -> Job | None:
job.stage_message = "Stopped"
return job
- job.resume_attempts += 1
+ # resume_attempts counts the runs the queue started, not the restarts: a
+ # restored queue waits for Start, and a job that never ran cannot be what
+ # took the process down. Two starts are the first run and its resume.
if job.resume_attempts > _MAX_RESUME_ATTEMPTS:
job.status = "error"
job.stage_message = "Error: Interrupted"
@@ -350,6 +756,7 @@ def reset_for_rerun(job: Job, job_dir: Path) -> None:
job.cancel_requested = False
job.stop_requested = False
job.error = None
+ job.error_detail = None
job.stems = []
job.mix_url = None
job.version += 1
@@ -363,46 +770,175 @@ def take_pending_resume() -> list[str]:
return ids
+def _sync_tracks(job: Job, job_dir: Path) -> bool:
+ """Reconcile a loaded job's recording tracks with its takes on disk, so
+ takes recorded before tracks existed come back as a track each. Never
+ fatal: restore() runs at import time."""
+ if not job.takes and not job.tracks and not (job_dir / "takes").is_dir():
+ return False
+ try:
+ # Takes are files first. A record without them (0.20.0 drops them)
+ # gets the finished ones on disk, or reconciling would take every
+ # track's active take away and write that to tracks.json. A take
+ # whose folder outlived its delete comes back with them, as it does
+ # when the song is rebuilt from its folder.
+ found = [] if job.takes else recover_takes(job_dir)
+ job.tracks, job.takes, changed = reconcile_tracks(job_dir, job.tracks, job.takes or found)
+ except Exception:
+ logger.warning("could not reconcile tracks for %s", job.id, exc_info=True)
+ return False
+ return changed or bool(found)
+
+
+def _stopped_from_source(job_dir: Path) -> Job | None:
+ """A song stopped before it was separated, rebuilt from the source it kept,
+ or None when the file Extract would run (the first source.*, as
+ jobqueue._find_local_source picks it) is not a type an upload can be.
+
+ Such a folder has no stems to rebuild from, so it stayed out of the
+ registry, out of reach of Extract, and the auto-delete sweep took it with
+ the only copy of an upload. Not a link's download cut off part way
+ (source.webm.part), and not a folder that is or holds a link: Extract
+ would write through it."""
+ try:
+ if not is_real_entry(job_dir) or not all(is_real_entry(p) for p in job_dir.iterdir()):
+ return None
+ source = next((p for p in sorted(job_dir.glob("source.*")) if p.is_file()), None)
+ created_at = job_dir.stat().st_mtime
+ except OSError:
+ logger.warning("could not read %s", job_dir, exc_info=True)
+ return None
+ if source is None or source.suffix.lower() not in UPLOAD_EXTS:
+ return None
+ # Placeholder, as for stems without metadata.json: a song stopped before
+ # it finished has no title there, so its name went with the registry.
+ title = f"Recovered track {job_dir.name[:6]}"
+ job = Job(
+ id=job_dir.name,
+ status="stopped",
+ stage_message="Stopped",
+ title=title,
+ source_url=f"local:{title}",
+ source_format=source.suffix[1:].lower(),
+ selected_stems=list(STEM_NAMES),
+ created_at=created_at,
+ )
+ meta = _read_metadata(job_dir / "metadata.json")
+ if isinstance(meta, dict):
+ _read_meta_fields(job, meta, ("created_at",))
+ return job
+
+
+def _read_metadata(meta_path: Path) -> Any:
+ """metadata.json as parsed, {} when there is none, or None when it cannot
+ be read as JSON."""
+ try:
+ return json.loads(meta_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return {}
+ except (OSError, RecursionError, ValueError):
+ logger.warning("could not read %s", meta_path, exc_info=True)
+ return None
+
+
+# The last file a separation writes into stems/ (mark_stems_complete): a folder
+# without it was cut off part way.
+STEMS_COMPLETE = ".complete"
+
+
+def mark_stems_complete(job_dir: Path) -> None:
+ """Record that a separation wrote every stem file it makes in stems/.
+ Called last, so a kill at any point before leaves a folder without the
+ marker. Every stem file and the folder are on the disk before the marker
+ is: a power cut could otherwise keep the marker and lose a stem's bytes.
+ Raises OSError."""
+ stems_dir = job_dir / "stems"
+ for name in stem_names_on_disk(stems_dir):
+ # Opened for writing: Windows only flushes a handle that can write.
+ with open(stem_path(stems_dir, name), "r+b") as fh:
+ os.fsync(fh.fileno())
+ fsync_dir(stems_dir)
+ _write_atomic(stems_dir / STEMS_COMPLETE, "")
+
+
+def _backfill_stems_complete(job_dir: Path) -> None:
+ """The marker for a song made before it, which the registry holds as done,
+ so a registry lost later still brings it back (songs from before 0.2 have
+ no metadata.json either). Its stems are long on the disk: only the marker
+ is written. Never fatal: restore() runs at import time."""
+ stems_dir = job_dir / "stems"
+ try:
+ if (stems_dir / STEMS_COMPLETE).is_file() or not stem_names_on_disk(stems_dir):
+ return
+ _write_atomic(stems_dir / STEMS_COMPLETE, "")
+ except OSError:
+ logger.warning("could not mark the stems of %s complete", job_dir.name, exc_info=True)
+
+
def _recover_done_job(job_dir: Path) -> Job | None:
+ found = _rebuild_from_folder(job_dir)
+ return found[0] if found is not None else None
+
+
+def _rebuild_from_folder(job_dir: Path) -> tuple[Job, bool] | None:
+ """A finished song rebuilt from its folder, and whether its metadata.json
+ held the SAVED_FIELDS (a folder written before they were kept has none).
+ None for a folder whose separation did not finish."""
stems_dir = job_dir / "stems"
if not stems_dir.is_dir():
return None
+ # A restart between the split finishing and its next registry persist
+ # would otherwise report the job as never split -- derive from disk the
+ # same way `stems` below does, so the recovered library entry doesn't
+ # regress (#275). Both or neither: the pair moves in with two renames
+ # (vocal_split._move_in), and a kill between them leaves a lead alone.
+ has_split = all(stem_exists(stems_dir, name) for name in EXTRA_STEM_NAMES)
+ vocal_split = "done" if has_split else "none"
+ # WAV or MP3, whichever the song was stored as (#783).
stems = [
- {"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"}
- for name in ("original", *STEM_NAMES, *EXTRA_STEM_NAMES)
- if (stems_dir / f"{name}.wav").is_file()
+ {"name": name, "url": stem_url(job_dir.name, stems_dir, name)}
+ for name in ("original", *STEM_NAMES, *(EXTRA_STEM_NAMES if has_split else ()))
+ if stem_exists(stems_dir, name)
]
if not stems:
return None
+ meta_path = job_dir / "metadata.json"
+ meta = _read_metadata(meta_path)
+ # metadata.json starts with the add time (keep_added_time), and a finished
+ # run writes it whole, the title always among it. Only a file with the add
+ # time and no title is from before the song was done: any other one,
+ # unreadable included, is taken as a finished run's, as it always was.
+ started_only = isinstance(meta, dict) and "created_at" in meta and "title" not in meta
+ finished_meta = meta_path.is_file() and not started_only
+ if not isinstance(meta, dict):
+ meta = {} # read as none, so the folders after this one still rebuild
+ # A song made before the marker gets it while the registry holds it
+ # (_backfill_stems_complete). One whose record was already gone has the
+ # metadata.json of its finished run, now written only after the marker.
+ if not (stems_dir / STEMS_COMPLETE).is_file() and not finished_meta:
+ logger.warning(
+ "%s holds stems of a separation that did not finish; not rebuilt as done",
+ job_dir.name,
+ )
+ return None
mix_url = None
- if (stems_dir / "mix.wav").is_file():
- mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav"
+ if stem_exists(stems_dir, "mix"):
+ mix_url = stem_url(job_dir.name, stems_dir, "mix")
selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES)
- # A restart between the split finishing and its next registry persist
- # would otherwise report the job as never split -- derive from disk the
- # same way `stems` above does, so the recovered library entry doesn't
- # regress (#275).
- has_split = all((stems_dir / f"{name}.wav").is_file() for name in EXTRA_STEM_NAMES)
- vocal_split = "done" if has_split else "none"
- meta_path = job_dir / "metadata.json"
- meta: dict = {}
- if meta_path.is_file():
+ if not finished_meta:
+ # Crash window (#284): the process died between the stems being
+ # marked complete and the metadata write, leaving a complete stems dir
+ # that used to be unrecoverable. Recover with a placeholder title and
+ # write it into metadata.json immediately, beside the add time, so the
+ # NEXT restart takes the normal path -- self-healing, not a permanent
+ # special case.
+ meta["title"] = f"Recovered track {job_dir.name[:6]}"
try:
- meta = json.loads(meta_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- pass
- else:
- # Crash window (#284): the process died between status=done and the
- # metadata write, leaving a complete stems dir that used to be
- # unrecoverable. Recover with a placeholder title and write a minimal
- # metadata.json immediately, so the NEXT restart takes the normal
- # path -- self-healing, not a permanent special case.
- meta = {"title": f"Recovered track {job_dir.name[:6]}"}
- try:
- meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
+ # Replaced, not written through: the file may hold the add time.
+ _write_atomic(meta_path, json.dumps(meta, indent=2) + "\n")
except OSError:
logger.warning("could not write recovery metadata for %s", job_dir.name, exc_info=True)
- return Job(
+ job = Job(
id=job_dir.name,
status="done",
progress=1.0,
@@ -421,6 +957,7 @@ def _recover_done_job(job_dir: Path) -> Job | None:
lufs=meta.get("lufs"),
peak_db=meta.get("peak_db"),
dynamic_range=meta.get("dynamic_range"),
+ loudness_version=meta.get("loudness_version"),
tempo_stability=meta.get("tempo_stability"),
stem_presence=meta.get("stem_presence"),
sections=meta.get("sections"),
@@ -433,30 +970,151 @@ def _recover_done_job(job_dir: Path) -> Job | None:
# The file is the record: metadata.json predates it for most jobs.
has_lyrics=(job_dir / LYRICS_FILE).is_file(),
vocal_split=vocal_split,
+ stem_format=meta.get("stem_format") if meta.get("stem_format") in ("wav", "mp3") else None,
+ stem_mp3_kbps=meta.get("stem_mp3_kbps")
+ if isinstance(meta.get("stem_mp3_kbps"), int)
+ else None,
+ # Takes are files first: their folders survive whatever the registry
+ # lost, so the lanes come back with the job.
+ takes=recover_takes(job_dir),
+ )
+ # loudness_remeasured too (#923): checked like a registry record, so a
+ # hand-edited value of the wrong type keeps the default instead of loading.
+ _read_meta_fields(
+ job, meta, (*SAVED_FIELDS, "has_video", "video_status", "loudness_remeasured")
)
+ job.selected_stems = clean_stems(job.selected_stems) or selected
+ # The add time is there from the start: it says nothing of the rest.
+ return job, any(name in meta for name in SAVED_FIELDS if name != "created_at")
+
+
+def _read_meta_fields(job: Job, meta: dict, names: Iterable[str]) -> None:
+ """Set each of `names` that metadata.json holds on the job. A file on disk,
+ so read through the same checks as a registry record: a value of the wrong
+ type keeps what the job has instead of loading."""
+ found = {name: meta[name] for name in names if name in meta}
+ if not found:
+ return
+ kept = Job.from_record({**found, "id": job.id})
+ for name in found.keys() - set(_repair_field_types(kept)):
+ setattr(job, name, getattr(kept, name))
+
+
+@contextmanager
+def operation(name: Operation) -> Iterator[None]:
+ """Register the processes started inside as `name`'s (see _operation)."""
+ token = _operation.set(name)
+ try:
+ yield
+ finally:
+ _operation.reset(token)
def set_proc(job_id: str, proc: subprocess.Popen | None) -> None:
+ """Register `proc` as the running process of `job_id`'s current operation,
+ or clear it with None.
+
+ A pipeline process registered after its job's cancel is terminated here
+ (#748). The cancel found nothing to terminate when it landed before this,
+ during the device probe ahead of the Demucs worker for one, so the trashed
+ song kept separating to the end. Checked under the lock the cancel's
+ get_proc takes, so one of the two always sees the other."""
+ key = (job_id, _operation.get())
with _lock:
if proc is None:
- _procs.pop(job_id, None)
- else:
- _procs[job_id] = proc
+ _procs.pop(key, None)
+ return
+ _procs[key] = proc
+ job = _jobs.get(job_id)
+ cancelled = (
+ key[1] == "pipeline"
+ and job is not None
+ and job.cancel_requested
+ and job.status not in FINISHED_STATUSES
+ )
+ if cancelled and proc.poll() is None:
+ proc.terminate()
-def reset_all(jobs_dir: Path) -> list[str]:
- """Delete every job directory and the registry file, clearing the
- in-memory registry too. Desktop-only factory reset (Settings -> General
- -> "Reset app data") -- the caller is responsible for checking no job is
- actively running first (deleting a running job's directory out from
- under it would corrupt that job, not just reset the library)."""
- with _lock:
+def is_real_entry(entry: Path) -> bool:
+ """Whether `entry` is itself in its folder, not a link or a Windows
+ junction to somewhere else. Removing what is found by walking through one
+ deletes whatever it points at."""
+ return entry.resolve() == entry.parent.resolve() / entry.name
+
+
+def job_dirs(folder: Path) -> list[Path]:
+ """The job folders directly inside `folder` (the jobs folder, or its
+ failed/ quarantine): a job id for a name, and a real directory. The jobs
+ folder can be shared with other things, so nothing else in it is a job."""
+ if not folder.is_dir():
+ return []
+ return [
+ d for d in folder.iterdir() if JOB_ID_RE.match(d.name) and d.is_dir() and is_real_entry(d)
+ ]
+
+
+def holds_job_files(job_dir: Path) -> bool:
+ """Whether a job-id folder holds something StemDeck writes into a job. The
+ name alone does not make it ours: twelve hex characters is also a Docker
+ container's short id. A folder that cannot be read is not ours to remove."""
+ try:
+ return any(
+ (job_dir / name).exists() for name in ("stems", TAKES_DIRNAME, "metadata.json")
+ ) or any(job_dir.glob("source.*"))
+ except OSError:
+ return False
+
+
+def quarantine_dirs(jobs_dir: Path) -> list[Path]:
+ """The job folders in failed/ that are StemDeck's quarantine: the runner
+ writes error.txt into each before moving it there. Empty when failed/ is a
+ link, or holds only someone else's folders."""
+ failed_root = jobs_dir / "failed"
+ if not is_real_entry(failed_root):
+ return []
+ return [d for d in job_dirs(failed_root) if (d / "error.txt").is_file()]
+
+
+# The files reset removes beside the job folders: the registry, its temp and
+# set-aside copies (_write_atomic, quarantine_corrupt, _keep_bad_records), and
+# the desktop shell's store (#403).
+_OWN_FILE_RE = re.compile(
+ r"(registry\.json(\.(corrupt|bad)-[0-9a-f-]+)?|\.registry\.json\.[\w.-]+\.tmp|user-data\.json)\Z"
+)
+
+
+def reset_all(jobs_dir: Path) -> tuple[list[str], list[str]]:
+ """Delete StemDeck's entries in the jobs folder, clearing the in-memory
+ registry too. Factory reset (Settings -> General -> "Reset app data") --
+ the caller is responsible for checking no job is actively running first
+ (deleting a running job's directory out from under it would corrupt that
+ job, not just reset the library).
+
+ The jobs folder can be shared with other things (a volume mapped onto a
+ share, a folder the user already had), so only what StemDeck made goes:
+ job folders it has a record of or that hold job files, its quarantined
+ failures in failed/, and its own files. Nothing behind a link is touched. Returns the
+ names it could not remove and the names it left alone."""
+ # A save still writing its snapshot finishes first: replacing the file
+ # after the loop below removed it brought the old library back.
+ with _write_lock, _lock:
+ known = set(_jobs) | _deleted
_jobs.clear()
_procs.clear()
if not jobs_dir.is_dir():
- return []
+ return [], []
+ failed_root = jobs_dir / "failed"
+ quarantined = quarantine_dirs(jobs_dir)
+ targets = [d for d in job_dirs(jobs_dir) if d.name in known or holds_job_files(d)]
+ targets += quarantined
+ targets += [
+ f
+ for f in jobs_dir.iterdir()
+ if _OWN_FILE_RE.match(f.name) and f.is_file() and is_real_entry(f)
+ ]
failed: list[str] = []
- for entry in jobs_dir.iterdir():
+ for entry in targets:
try:
if entry.is_dir():
shutil.rmtree(entry)
@@ -465,18 +1123,29 @@ def reset_all(jobs_dir: Path) -> list[str]:
except OSError:
logger.warning("reset: could not remove %s", entry, exc_info=True)
failed.append(entry.name)
+ # failed/ itself goes only if it is empty now: rmdir refuses otherwise, and
+ # anything still in it is not ours.
+ if quarantined and not any(failed_root.iterdir()):
+ try:
+ failed_root.rmdir()
+ except OSError:
+ logger.warning("reset: could not remove %s", failed_root, exc_info=True)
+ failed.append(failed_root.name)
+ skipped = sorted(e.name for e in jobs_dir.iterdir() if e.name not in failed)
# Anything that survived is still a job-shaped directory on disk, so the
# next start would adopt it and the "reset" library would refill itself.
# Record it as deleted and persist that, which also recreates the registry
- # file this loop just removed.
+ # file this loop just removed. A known job left alone because it is a link
+ # is one of these too.
surviving = [name for name in failed if JOB_ID_RE.match(name)]
+ surviving += [name for name in skipped if name in known]
if surviving:
with _lock:
_deleted.update(surviving)
persist(jobs_dir)
- return failed
+ return failed, skipped
-def get_proc(job_id: str) -> subprocess.Popen | None:
+def get_proc(job_id: str, op: Operation = "pipeline") -> subprocess.Popen | None:
with _lock:
- return _procs.get(job_id)
+ return _procs.get((job_id, op))
diff --git a/app/core/settings.py b/app/core/settings.py
index c30aa8f7..8cb2301a 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -11,12 +11,16 @@
- `export_sample_rate` — sample rate for exported mixes/regions (WAV/FLAC/MP3).
- `demucs_device` — compute device for separation: auto | cuda | mps | cpu.
- `separation_quality` — demucs shift-averaging: standard | best (2x slower).
+- `idle_unload_minutes` - idle minutes before the separation models unload (0 never).
- `cookies_file` — optional cookies.txt handed to yt-dlp for YouTube.
- `auto_delete_jobs` — whether finished jobs are deleted after a while (off).
- `auto_delete_days` — how long they are kept when that is on.
- `acoustid_api_key` - the user's AcoustID key, for fingerprint identification.
- `discogs_token` - the user's Discogs token, for band profiles.
- `transcribe_lyrics` - Whisper lyrics when none are found: auto | on | off.
+- `video_encoder` - H.264 encoder of the take render: auto | cpu | an encoder.
+- `stem_format` - how new songs' stems are stored: wav | mp3 (#783).
+- `stem_mp3_kbps` - the MP3 bitrate when stem_format is mp3: 128 | 192 | 256.
Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
@@ -35,8 +39,14 @@
from app.core.config import (
DATA_DIR,
+ DEFAULT_STEM_STORAGE,
+ IDLE_UNLOAD_MINUTES,
+ IDLE_UNLOAD_MINUTES_MAX,
MAX_DURATION_SEC,
PLAYLIST_MAX_ITEMS,
+ STEM_FORMAT_CHOICES,
+ STEM_MP3_KBPS_CHOICES,
+ VIDEO_ENCODER_CHOICES,
VIDEO_MAX_HEIGHT,
available_torch_devices,
detect_torch_device,
@@ -76,15 +86,6 @@ def _default_allow_network() -> bool:
return os.environ.get("STEMDECK_DESKTOP") != "1"
-def _mirror_path() -> Path | None:
- """Where the per-user copy lives, or None when the shell did not set one.
-
- The path comes from the shell (STEMDECK_SETTINGS_MIRROR) so the platform
- logic stays in one place -- see _mirror_settings."""
- target = os.environ.get("STEMDECK_SETTINGS_MIRROR", "").strip()
- return Path(target) if target else None
-
-
def _read_json_dict(path: Path) -> dict | None:
"""Parse `path` as a JSON object.
@@ -102,6 +103,23 @@ def _read_json_dict(path: Path) -> dict | None:
return data if isinstance(data, dict) else None
+def fsync_dir(directory: Path) -> None:
+ """Make a rename in `directory` durable. POSIX only: Windows cannot open a
+ directory for fsync, and NTFS journals the rename itself."""
+ if os.name == "nt":
+ return
+ try:
+ fd = os.open(directory, os.O_RDONLY)
+ except OSError:
+ return
+ try:
+ os.fsync(fd)
+ except OSError:
+ pass # some filesystems (network shares) refuse; the rename still happened
+ finally:
+ os.close(fd)
+
+
def _atomic_write_json(path: Path, data: dict) -> bool:
"""Write `data` to `path` so an interrupted write cannot destroy what was
there before.
@@ -109,35 +127,55 @@ def _atomic_write_json(path: Path, data: dict) -> bool:
write_text() truncates first and writes second, so a process that dies in
between leaves a file that exists and does not parse -- which _load then
could not distinguish from a first run, and the next setting change
- persisted a one-key file over both this and the mirror (#509). Same
+ persisted a one-key file over it (#509). Same
same-directory temp + replace the registry already uses; the temp name is
- unique per call so two concurrent writers cannot interleave on it."""
+ unique per call so two concurrent writers cannot interleave on it.
+
+ The bytes are on the disk before the rename, as in the registry: this file
+ holds jobs_dir, and a power cut that left it torn after a library move would
+ bring the app back to the folder the songs left."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
try:
- tmp.write_text(json.dumps(data), encoding="utf-8")
- tmp.replace(path)
+ with open(tmp, "w", encoding="utf-8") as fh:
+ fh.write(json.dumps(data))
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, path)
finally:
tmp.unlink(missing_ok=True)
+ fsync_dir(path.parent)
except Exception:
_log.warning("could not persist settings to %s", path, exc_info=True)
return False
return True
-def _quarantine_corrupt(path: Path) -> None:
- """Move an unusable settings file aside rather than leaving it to be
- overwritten by the next save.
+def quarantine_corrupt(path: Path, what: str = "settings") -> Path | None:
+ """Move an unusable file aside rather than leaving it to be overwritten by
+ the next save. Returns where it went, or None if it could not be moved.
Renaming keeps the bytes for diagnosis. Deleting or writing over them
- destroys the only remaining evidence of what the user had configured."""
+ destroys the only remaining evidence of what the user had configured.
+ Shared with the registry (app/core/registry.py), whose file has the same
+ problem; `what` only names the file in the log."""
try:
- target = path.with_name(f"{path.name}.corrupt-{int(time.time())}")
+ stamp = int(time.time())
+ target = path.with_name(f"{path.name}.corrupt-{stamp}")
+ n = 1
+ while target.exists(): # a second quarantine within the same second
+ target = path.with_name(f"{path.name}.corrupt-{stamp}-{n}")
+ n += 1
path.replace(target)
- _log.warning("settings at %s were unreadable; moved aside to %s", path, target)
+ _log.warning("%s at %s were unreadable; moved aside to %s", what, path, target)
+ return target
except OSError:
- _log.warning("could not move unreadable settings at %s aside", path, exc_info=True)
+ _log.warning("could not move unreadable %s at %s aside", what, path, exc_info=True)
+ return None
+
+
+_quarantine_corrupt = quarantine_corrupt
def _load() -> dict:
@@ -145,8 +183,7 @@ def _load() -> dict:
Conflating the two is what lost real user settings: a torn write left an
unparsable file, this returned {} exactly as it would on a first run, and
- the next set_*() then persisted a single key over both settings.json and
- the mirror that existed to protect it."""
+ the next set_*() then persisted a single key over it."""
if not _SETTINGS_PATH.exists():
return {} # no settings file yet — genuine first run; use defaults
@@ -154,19 +191,8 @@ def _load() -> dict:
if data is not None:
return data
- # The file is there but unusable. Preserve it, then try the per-user copy
- # the shell keeps outside the install directory.
+ # The file is there but unusable. Preserve it for diagnosis.
_quarantine_corrupt(_SETTINGS_PATH)
- mirror = _mirror_path()
- if mirror is not None:
- recovered = _read_json_dict(mirror)
- if recovered:
- _log.warning("recovered settings from mirror %s", mirror)
- # Put them back immediately. Without this the recovery only lasts
- # until the next start, which would read a now-absent primary and
- # silently fall back to defaults again.
- _atomic_write_json(_SETTINGS_PATH, recovered)
- return recovered
return {}
@@ -174,15 +200,6 @@ def _ensure() -> dict:
global _state
if _state is None:
_state = _load()
- # Seed the per-user copy from settings that already exist. Mirroring
- # only on _save() would protect nobody who configured StemDeck before
- # this shipped and never opens Settings again -- their next install
- # would still start empty. Safe against recursion (_state is assigned
- # first) and against clobbering: an empty dict means a genuine first
- # run, and overwriting a good copy with it is exactly the data loss
- # this whole mechanism exists to prevent.
- if _state:
- _mirror_settings()
return _state
@@ -193,38 +210,7 @@ def _save() -> bool:
is still reported back, because one caller (set_jobs_dir) is coupled to
something irreversible enough that silently swallowing a failure there
would be actively misleading rather than merely inconvenient (#403)."""
- if not _atomic_write_json(_SETTINGS_PATH, _ensure()):
- return False
- _mirror_settings()
- return True
-
-
-def _mirror_settings() -> None:
- """Keep a per-user copy of settings.json outside the install directory.
-
- A Windows portable package keeps its data in `/data` (#399), so
- settings.json lives *inside the install*. Upgrading by extracting the new
- zip to a fresh folder therefore started that install with no settings at
- all: the stems location, port, compute device, quality and language were
- all silently back to defaults, and a relocated library looked empty.
-
- The desktop shell already restores from this copy -- `ensure_workspace`
- seeds a fresh portable install from it before the backend ever reads
- settings.json. Only the write half was missing, because #399 moved the
- data directory and nothing took over writing the old location.
-
- Best-effort by definition: this is a redundant copy, and failing to write
- it must never fail the setting the user just changed. The path comes from
- the shell (STEMDECK_SETTINGS_MIRROR) so the platform logic stays in one
- place and both halves cannot drift apart.
- """
- path = _mirror_path()
- if path is None:
- return
- # Same-directory temp + replace: a torn write here would be restored
- # verbatim into the user's next install. _atomic_write_json also gives the
- # temp file a unique name, so two writers cannot interleave on it.
- _atomic_write_json(path, _ensure())
+ return _atomic_write_json(_SETTINGS_PATH, _ensure())
def _num(v: object) -> int | None:
@@ -300,6 +286,30 @@ def _default_auto_sections() -> bool:
return os.environ.get("STEMDECK_AUTO_SECTIONS", "").strip() == "1"
+def _default_auto_lyrics() -> bool:
+ """Lyrics found while a song is imported (LRCLIB, then a Whisper
+ transcript of the vocals), off until the user turns Detect lyrics on.
+
+ Unlike Detect structure this is a standing choice, kept until changed.
+ STEMDECK_AUTO_LYRICS=1 turns it on for a deployment that wants it from
+ first boot.
+ """
+ return os.environ.get("STEMDECK_AUTO_LYRICS", "").strip() == "1"
+
+
+def get_auto_lyrics() -> bool:
+ with _LOCK:
+ v = _ensure().get("auto_lyrics")
+ return v if isinstance(v, bool) else _default_auto_lyrics()
+
+
+def set_auto_lyrics(value: bool) -> bool:
+ with _LOCK:
+ _ensure()["auto_lyrics"] = bool(value)
+ _save()
+ return bool(value)
+
+
def get_auto_sections() -> bool:
with _LOCK:
v = _ensure().get("auto_sections")
@@ -421,20 +431,36 @@ def set_cookies_file(value: str | None) -> str | None:
Raises ValueError when the path does not point at a readable file, so the
Settings UI can say so immediately rather than the user discovering it as a
failed import an hour later.
+
+ Only a file inside the data folder is accepted, and a bare name is looked
+ up there. Anyone who can reach Settings (the LAN, with network access on)
+ could otherwise point yt-dlp at any file on the machine, and yt-dlp echoes
+ each line it cannot parse into the backend log. One error for outside,
+ missing and unreadable, so the answer says nothing about other paths. A
+ path saved before this rule keeps working until it is changed.
"""
with _LOCK:
if value is None or not str(value).strip():
_ensure().pop("cookies_file", None)
_save()
return None
- resolved = Path(str(value)).expanduser().resolve()
- if not resolved.is_file():
- raise ValueError("cookies file not found")
+ resolved = None
try:
- with resolved.open("rb") as fh:
- fh.read(1)
- except OSError as e:
- raise ValueError("cookies file is not readable") from e
+ # Checked by name before resolve() touches the disk: resolving a
+ # \\host\share path opens it, and Windows sends the user's NTLM
+ # hash to that host. Checked again after, for a symlink out.
+ named = Path(os.path.normpath(DATA_DIR / Path(str(value)).expanduser()))
+ if named.is_relative_to(DATA_DIR):
+ candidate = named.resolve()
+ if candidate.is_relative_to(DATA_DIR.resolve()) and candidate.is_file():
+ with candidate.open("rb") as fh:
+ fh.read(1)
+ resolved = candidate
+ except (OSError, ValueError, RuntimeError):
+ # A NUL byte, a bad Windows name or a symlink loop: the same answer.
+ resolved = None
+ if resolved is None:
+ raise ValueError("cookies file must be a readable file in the data folder")
_ensure()["cookies_file"] = str(resolved)
_save()
return str(resolved)
@@ -597,10 +623,122 @@ def set_separation_quality(value: str) -> str:
return choice
+# ── idle_unload_minutes ──
+# Minutes with no separation or analysis running before the separation worker
+# and the beat model are unloaded (#975), 0 for never. Read on every check of the idle timer
+# (app/pipeline/idle_unload.py), so a change applies without a restart.
+# STEMDECK_IDLE_UNLOAD_MINUTES seeds the default; a value saved here wins.
+def get_idle_unload_minutes() -> int:
+ with _LOCK:
+ v = _num(_ensure().get("idle_unload_minutes"))
+ if v is None:
+ return IDLE_UNLOAD_MINUTES
+ return max(0, min(IDLE_UNLOAD_MINUTES_MAX, v))
+
+
+def set_idle_unload_minutes(value: int) -> int:
+ clamped = max(0, min(IDLE_UNLOAD_MINUTES_MAX, int(value)))
+ with _LOCK:
+ _ensure()["idle_unload_minutes"] = clamped
+ _save()
+ return clamped
+
+
+# ── stem_format / stem_mp3_kbps ──
+# How a song's stems are stored once it finishes (#783): "wav" or "mp3" at a
+# CBR bitrate. Read when a job finishes (app/pipeline/stem_storage.py), so a
+# change applies to the next song without a restart; songs already in the
+# library keep whatever they were stored as. STEMDECK_STEM_FORMAT and
+# STEMDECK_STEM_MP3_KBPS seed the defaults for Docker and Unraid; a choice
+# saved in Settings wins over them, as for separation_quality.
+def _default_stem_format() -> str:
+ env = os.environ.get("STEMDECK_STEM_FORMAT", "").strip().lower()
+ return env if env in STEM_FORMAT_CHOICES else DEFAULT_STEM_STORAGE[0]
+
+
+def _default_stem_mp3_kbps() -> int:
+ raw = os.environ.get("STEMDECK_STEM_MP3_KBPS", "").strip().lower().removesuffix("k")
+ try:
+ kbps = int(raw)
+ except ValueError:
+ return DEFAULT_STEM_STORAGE[1]
+ return kbps if kbps in STEM_MP3_KBPS_CHOICES else DEFAULT_STEM_STORAGE[1]
+
+
+def get_stem_format() -> str:
+ with _LOCK:
+ v = _ensure().get("stem_format")
+ return v if isinstance(v, str) and v in STEM_FORMAT_CHOICES else _default_stem_format()
+
+
+def get_stem_mp3_kbps() -> int:
+ with _LOCK:
+ v = _ensure().get("stem_mp3_kbps")
+ ok = isinstance(v, int) and not isinstance(v, bool) and v in STEM_MP3_KBPS_CHOICES
+ return v if ok else _default_stem_mp3_kbps()
+
+
+def check_stem_format(value: object) -> str:
+ """The whitelisted format for ``value``, or ValueError (never echoing it)."""
+ choice = value.strip().lower() if isinstance(value, str) else ""
+ if choice not in STEM_FORMAT_CHOICES:
+ raise ValueError("stem_format must be one of: " + ", ".join(STEM_FORMAT_CHOICES))
+ return STEM_FORMAT_CHOICES[STEM_FORMAT_CHOICES.index(choice)]
+
+
+def check_stem_mp3_kbps(value: object) -> int:
+ """The whitelisted bitrate for ``value``, or ValueError (never echoing it)."""
+ kbps = value if isinstance(value, int) and not isinstance(value, bool) else None
+ if kbps not in STEM_MP3_KBPS_CHOICES:
+ raise ValueError(
+ "stem_mp3_kbps must be one of: " + ", ".join(map(str, STEM_MP3_KBPS_CHOICES))
+ )
+ return kbps
+
+
+def set_stem_storage(fmt: object = None, kbps: object = None) -> tuple[str, int]:
+ """Persist either or both, after both are validated: a request with one
+ bad value changes nothing. Returns what now applies."""
+ choice = check_stem_format(fmt) if fmt is not None else None
+ rate = check_stem_mp3_kbps(kbps) if kbps is not None else None
+ with _LOCK:
+ if choice is not None:
+ _ensure()["stem_format"] = choice
+ if rate is not None:
+ _ensure()["stem_mp3_kbps"] = rate
+ if choice is not None or rate is not None:
+ _save()
+ return get_stem_format(), get_stem_mp3_kbps()
+
+
+# ── video_encoder ──
+# The H.264 encoder of the take render (app/pipeline/video_encoder.py): "auto"
+# (default) uses NVENC on Windows when it passes a real test encode here, else
+# libx264, "cpu" always libx264, an encoder name only that one (libx264 when it fails).
+# Read per render. STEMDECK_VIDEO_ENCODER overrides it (video_encoder.policy).
+def get_video_encoder() -> str:
+ with _LOCK:
+ v = _ensure().get("video_encoder")
+ return v if isinstance(v, str) and v in VIDEO_ENCODER_CHOICES else "auto"
+
+
+def set_video_encoder(value: str) -> str:
+ choice = (value or "").strip().lower()
+ if choice not in VIDEO_ENCODER_CHOICES:
+ raise ValueError("video_encoder must be one of: " + ", ".join(VIDEO_ENCODER_CHOICES))
+ # The stored value is the whitelist's own string, never the request's.
+ choice = VIDEO_ENCODER_CHOICES[VIDEO_ENCODER_CHOICES.index(choice)]
+ with _LOCK:
+ _ensure()["video_encoder"] = choice
+ _save()
+ return choice
+
+
# ── transcribe_lyrics ──
# Whether a job with no lyrics found gets them transcribed from its vocals stem
# with Whisper (app/pipeline/transcribe.py). "auto" (default) does it when the
-# job was separated on an NVIDIA GPU (cuda) only. Anywhere else, Apple's MPS
+# job was separated on a cuda GPU only: NVIDIA, or AMD under a ROCm build of
+# torch, which serves it as cuda too (#825). Anywhere else, Apple's MPS
# included, Whisper runs on the CPU, about 200 s a song rather than 30 s, so
# "auto" leaves it off; "on" and "off" force it. Read per job, so
# a change applies to the next import. STEMDECK_TRANSCRIBE_LYRICS seeds the
diff --git a/app/core/stem_files.py b/app/core/stem_files.py
new file mode 100644
index 00000000..2ce92858
--- /dev/null
+++ b/app/core/stem_files.py
@@ -0,0 +1,136 @@
+"""Where a stem's audio file is, whatever format the song keeps it in (#783).
+
+A library holds songs stored as WAV (every song before #783, and any song made
+with the WAV setting) and songs stored as MP3. Every reader that opens a stem
+of a finished song goes through `stem_path`, so none of them needs to know
+which kind of song it has.
+
+The rule is the file on disk, WAV first:
+
+- A WAV song has `.wav`. It may also have `.mp3`, but that is the
+ download cache GET /stems/{name}.mp3 writes beside the WAV (a VBR transcode),
+ never the stem itself, so the WAV always wins.
+- An MP3 song has only `.mp3`: its WAVs are deleted once the MP3s are
+ verified (app/pipeline/stem_storage.py).
+- While that step runs, and if it fails, the WAVs are still there and win.
+
+So the answer is right in every state a song's folder can be in, without
+reading the job record. `job.stem_format` records what the song was stored as,
+for the page and for diagnostics.
+
+MP3 stems decode to exactly the WAV's samples in length and position: the LAME
+header ffmpeg writes carries the encoder delay and padding, and ffmpeg,
+libsndfile and Chromium's decodeAudioData all honour it (measured, see
+tests/test_stem_storage.py). The storage step verifies the length with both
+server-side decoders before it deletes anything.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import shutil
+import subprocess
+import tempfile
+from collections.abc import Iterator
+from pathlib import Path
+
+from app.core.config import TIMEOUT_FFMPEG, ffmpeg_executable
+
+# In the order they are looked for. WAV first: see the module docstring.
+STEM_EXTS: tuple[str, ...] = (".wav", ".mp3")
+
+_MEDIA_TYPES = {".wav": "audio/wav", ".mp3": "audio/mpeg"}
+
+
+def stem_path(stems_dir: Path, name: str) -> Path:
+ """The file holding stem `name`: `.wav` when it exists, else
+ `.mp3` when that exists, else the `.wav` path (which does not
+ exist, so a caller's own is_file() check fails as it always did).
+
+ `name` must already be whitelisted by the caller."""
+ wav = stems_dir / f"{name}.wav"
+ if wav.is_file():
+ return wav
+ mp3 = stems_dir / f"{name}.mp3"
+ if mp3.is_file():
+ return mp3
+ return wav
+
+
+def stem_exists(stems_dir: Path, name: str) -> bool:
+ return stem_path(stems_dir, name).is_file()
+
+
+def stem_ext(path: Path) -> str:
+ """ "wav" or "mp3" for a stem file."""
+ return path.suffix.lower().removeprefix(".")
+
+
+def stem_media_type(path: Path) -> str:
+ return _MEDIA_TYPES.get(path.suffix.lower(), "application/octet-stream")
+
+
+def stem_url(job_id: str, stems_dir: Path, name: str) -> str:
+ """The URL the page loads stem `name` from, ending in the file's own
+ extension so the browser and a download both get the right type."""
+ return f"/api/jobs/{job_id}/stems/{name}.{stem_ext(stem_path(stems_dir, name))}"
+
+
+def stem_names_on_disk(stems_dir: Path) -> list[str]:
+ """Every name with a stem file in `stems_dir`, sorted, each once."""
+ names: set[str] = set()
+ for ext in STEM_EXTS:
+ names.update(
+ p.stem for p in stems_dir.glob(f"*{ext}") if p.is_file() and not p.name.startswith(".")
+ )
+ return sorted(names)
+
+
+def is_stem_audio(path: Path) -> bool:
+ return path.suffix.lower() in STEM_EXTS
+
+
+@contextlib.contextmanager
+def decoded_wav(path: Path) -> Iterator[Path]:
+ """`path` itself when it is a WAV, else a float32 WAV decoded from it by
+ ffmpeg in a temporary folder beside it that is removed afterwards. Beside
+ it, not in the system temp folder, so the startup sweep
+ (collect.sweep_crash_leftovers) finds what a killed process left there.
+
+ For a reader that hands the stem to a third-party library which may not
+ decode MP3 the way ffmpeg does (the vocal split's audio-separator). Raises
+ RuntimeError when the decode fails."""
+ if path.suffix.lower() == ".wav":
+ yield path
+ return
+ folder = Path(tempfile.mkdtemp(prefix=".decode-", dir=path.parent))
+ try:
+ out = folder / f"{path.stem}.wav"
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(path),
+ "-map",
+ "0:a:0",
+ "-c:a",
+ "pcm_f32le",
+ "-f",
+ "wav",
+ "-y",
+ str(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 or not out.is_file():
+ raise RuntimeError(f"could not decode {path.name}")
+ yield out
+ finally:
+ shutil.rmtree(folder, ignore_errors=True)
diff --git a/app/core/stems_location.py b/app/core/stems_location.py
index 82a6e7bf..996ad709 100644
--- a/app/core/stems_location.py
+++ b/app/core/stems_location.py
@@ -13,12 +13,18 @@
from __future__ import annotations
+import contextlib
+import errno
+import filecmp
import logging
import os
import shutil
+import stat
from dataclasses import dataclass
from pathlib import Path
+from app.core.settings import fsync_dir
+
logger = logging.getLogger("stemdeck.stems_location")
@@ -59,7 +65,8 @@ class MoveResult:
target: str
moved_entries: int
bytes_moved: int
- same_filesystem: bool
+ # Copied rather than renamed: their originals wait for remove_moved.
+ copied: tuple[str, ...] = ()
def directory_size(path: Path) -> int:
@@ -109,8 +116,18 @@ def validate_target(target: Path, current: Path) -> Path:
# documents_store_path), so re-selecting a folder StemDeck already
# used must recognize it as "ours", same as registry.json.
ours = {"registry.json", "failed", "user-data.json"}
- if entries and not all(e.name in ours or _looks_like_job_dir(e) for e in entries):
+ if entries and not all(
+ e.name in ours
+ or _looks_like_job_dir(e)
+ or _left_by_os_or_stemdeck(e.name)
+ or _partial_copy(e.name, current)
+ for e in entries
+ ):
raise StemsLocationError("Pick an empty folder, or one StemDeck already uses.")
+ if _holds_another_library(target, current) and _holds_songs(current):
+ raise StemsLocationError(
+ "That folder already holds another StemDeck library. Pick an empty folder instead."
+ )
else:
try:
target.mkdir(parents=True, exist_ok=True)
@@ -122,12 +139,58 @@ def validate_target(target: Path, current: Path) -> Path:
return target
+# The files that hold a library's records. move_library never writes over a file
+# already at the target, so if both folders have one of these, ours would stay
+# behind and its favourites, Trash and tags would be lost (#798).
+_LIBRARY_RECORDS = ("registry.json", "user-data.json")
+
+
+def _holds_another_library(target: Path, current: Path) -> bool:
+ """Whether the target has library records of its own that the move would
+ leave ours behind for.
+
+ A half-finished move of this same library is not another library: a renamed
+ entry no longer exists in the source, so a records file present in only one
+ place is our own progress. Both exist when a move across drives copied it
+ and has not removed the original yet, and then the two files are identical.
+ """
+ for name in _LIBRARY_RECORDS:
+ theirs = target / name
+ mine = current / name
+ if not os.path.lexists(theirs) or not mine.is_file():
+ continue
+ # A link in the target could point anywhere; never treat it as ours.
+ if theirs.is_symlink() or not theirs.is_file():
+ return True
+ try:
+ if not filecmp.cmp(mine, theirs, shallow=False):
+ return True
+ except OSError:
+ return True
+ return False
+
+
+# What Finder (.DS_Store) and Explorer (desktop.ini, Thumbs.db) write into any
+# folder they show, and the registry copies StemDeck sets aside itself. None of
+# them makes a folder someone else's.
+_OS_FILES = frozenset({".ds_store", "desktop.ini", "thumbs.db"})
+_SET_ASIDE = ("registry.json.corrupt-", "registry.json.bad-")
+
+
+def _left_by_os_or_stemdeck(name: str) -> bool:
+ return name.lower() in _OS_FILES or name.startswith(_SET_ASIDE)
+
+
def _looks_like_job_dir(entry: Path) -> bool:
from app.core.config import JOB_ID_RE
return entry.is_dir() and bool(JOB_ID_RE.match(entry.name))
+def _holds_songs(folder: Path) -> bool:
+ return folder.is_dir() and any(_looks_like_job_dir(e) for e in folder.iterdir())
+
+
def move_library(current: Path, target: Path) -> MoveResult:
"""Move every stem directory (and top-level file, e.g. registry.json and
the desktop shell's user-data.json, #403) from `current` into `target`.
@@ -137,49 +200,210 @@ def move_library(current: Path, target: Path) -> MoveResult:
nothing here needs to know about user-data.json specifically -- it lives
inside the jobs folder like any other entry, so it comes along for free.
+ On the same drive an entry is renamed, which is atomic. Across drives it is
+ copied under a temporary name, checked against the original, put on the
+ disk and only then renamed to its real name, so a crash never leaves a song
+ cut short under a name a later attempt would take as finished. The
+ originals of copied entries stay (`MoveResult.copied`) until the caller has
+ saved the new location and calls remove_moved: until then the old folder is
+ whole and is still the one a restart reads.
+
On a failure partway, whatever has already moved stays moved and the error
- names the entry that stopped it. Rolling back a half-finished multi-gigabyte
- copy has its own failure modes, and the app can see a library in either
- place -- it is the setting, written only on success, that decides which one
- it reads.
+ names the entry that stopped it. A retry picks up where this stopped.
"""
current = current.expanduser().resolve()
target = target.expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
- same_fs = _same_filesystem(current, target)
moved = 0
moved_bytes = 0
-
+ copied: list[str] = []
+
+ # No song here and another library there: settings were lost while that
+ # library's drive was away, and StemDeck started in its default folder.
+ # Point at the library and bring nothing, or this folder's stray registry
+ # would be taken for the real one.
+ if not _holds_songs(current) and _holds_another_library(target, current):
+ return MoveResult(str(current), str(target), 0, 0)
if not current.exists():
- return MoveResult(str(current), str(target), 0, 0, same_fs)
+ return MoveResult(str(current), str(target), 0, 0)
+
+ for leftover in target.iterdir():
+ if _partial_copy(leftover.name, current):
+ _remove(leftover)
for entry in sorted(current.iterdir()):
destination = target / entry.name
- if destination.exists():
- # A previous, interrupted attempt already brought this one over.
- logger.info("stems move: %s already at the target, skipping", entry.name)
- continue
size = directory_size(entry) if entry.is_dir() else entry.stat().st_size
try:
- shutil.move(str(entry), str(destination))
+ if os.path.lexists(destination):
+ if _holds_all_of(destination, entry):
+ # An earlier attempt finished this one.
+ copied.append(entry.name)
+ continue
+ if _is_link(entry) or not _looks_like_job_dir(entry):
+ # Such as the target's own .DS_Store: never written over.
+ logger.info("stems move: a different %s is at the target", entry.name)
+ continue
+ # A song an earlier attempt cut short is copied again.
+ elif _renamed(entry, destination):
+ moved += 1
+ moved_bytes += size
+ continue
+ _copy_verified(entry, destination)
except OSError as e:
+ # strerror only: str(e) of some OSErrors (shutil.SpecialFileError)
+ # carries the full path.
raise StemsLocationError(
- f"Could not move {entry.name}: {e.strerror or e}. "
+ f"Could not move {entry.name}: {e.strerror or 'it could not be copied'}. "
f"Anything already moved is in the new folder."
) from e
+ copied.append(entry.name)
moved += 1
moved_bytes += size
- return MoveResult(str(current), str(target), moved, moved_bytes, same_fs)
+ return MoveResult(str(current), str(target), moved, moved_bytes, tuple(copied))
+
+
+def remove_moved(current: Path, names: tuple[str, ...]) -> None:
+ """Delete the originals of the entries move_library copied, once the new
+ location is saved. The records go first, so an old folder this stops
+ partway through holds no registry that could later read as another
+ library. One that cannot be deleted is only a leftover copy: logged."""
+ for name in sorted(names, key=lambda n: n not in _LIBRARY_RECORDS):
+ try:
+ _remove(current / name)
+ except OSError:
+ logger.warning("stems move: could not remove the original of %s", name, exc_info=True)
-def _same_filesystem(a: Path, b: Path) -> bool:
- """Whether the move is a rename or a copy. Only used to set expectations in
- the UI, so an unanswerable question is answered with "assume the slow one"."""
+def _is_link(path: Path) -> bool:
+ """A symlink or a Windows junction. Neither is ever followed: what it points
+ at is not the library's. The reparse tag, as shutil.rmtree checks it,
+ rather than os.path.isjunction, which Python 3.11 does not have. Only
+ Windows has junctions, and only its stat module names their tag."""
try:
- probe = a if a.exists() else a.parent
- other = b if b.exists() else b.parent
- return probe.stat().st_dev == other.stat().st_dev
+ st = path.lstat()
except OSError:
return False
+ if stat.S_ISLNK(st.st_mode):
+ return True
+ junction = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
+ return os.name == "nt" and getattr(st, "st_reparse_tag", 0) == junction
+
+
+def _remove(path: Path) -> None:
+ if path.is_dir() and not _is_link(path):
+ shutil.rmtree(path)
+ else:
+ path.unlink(missing_ok=True)
+
+
+def _renamed(entry: Path, destination: Path) -> bool:
+ try:
+ os.rename(entry, destination)
+ except OSError as e:
+ # Another drive, or a rename the OS refused (a file held open on
+ # Windows): copied instead.
+ logger.debug("stems move: renaming %s refused (errno %s)", entry.name, e.errno)
+ return False
+ return True
+
+
+# A copy across drives is written as `..moving` and renamed to only
+# once it is complete.
+_PARTIAL = ".moving"
+
+_LINK_REFUSED = "a link to a place outside the library cannot be moved to another drive"
+
+
+def _partial_copy(name: str, current: Path) -> bool:
+ """Whether `name` is an unfinished copy of one of the entries in `current`."""
+ if not (name.startswith(".") and name.endswith(_PARTIAL)) or not current.is_dir():
+ return False
+ return name[1 : -len(_PARTIAL)] in os.listdir(current)
+
+
+def _files(root: Path) -> dict[str, int]:
+ """Every file under `root` by relative path, with its size. Links are not
+ looked through."""
+ found: dict[str, int] = {}
+ pending = [root]
+ while pending:
+ for child in pending.pop().iterdir():
+ if _is_link(child):
+ continue
+ if child.is_dir():
+ pending.append(child)
+ else:
+ found[child.relative_to(root).as_posix()] = child.stat().st_size
+ return found
+
+
+def _holds_all_of(copy: Path, original: Path) -> bool:
+ """Whether `copy` already has every file of `original`, byte for byte. The
+ original is deleted on that answer, and a copy an older version left
+ without fsync can have the full size and lost data.
+
+ Every file, not equality: a crash while an earlier move deleted its
+ originals leaves `original` short of files that `copy` still has."""
+ if _is_link(copy) or _is_link(original):
+ return False
+ if original.is_dir():
+ if not copy.is_dir():
+ return False
+ have = _files(copy)
+ return all(
+ have.get(rel) == size and filecmp.cmp(original / rel, copy / rel, shallow=False)
+ for rel, size in _files(original).items()
+ )
+ return copy.is_file() and filecmp.cmp(original, copy, shallow=False)
+
+
+def _copy_file(src: Path, dst: Path) -> None:
+ shutil.copyfile(src, dst)
+ with open(dst, "rb+") as fh:
+ os.fsync(fh.fileno())
+ shutil.copystat(src, dst)
+
+
+def _copy_tree(src: Path, dst: Path) -> None:
+ dst.mkdir()
+ for child in src.iterdir():
+ if _is_link(child):
+ raise OSError(errno.ELOOP, _LINK_REFUSED)
+ if child.is_dir():
+ _copy_tree(child, dst / child.name)
+ else:
+ _copy_file(child, dst / child.name)
+ shutil.copystat(src, dst)
+ fsync_dir(dst)
+
+
+def _copy_verified(entry: Path, destination: Path) -> None:
+ """Copy `entry` to `destination` so that a crash at any moment leaves
+ either no copy under that name or a complete one on the disk. Sizes are
+ checked for a folder, every byte for a file (registry.json,
+ user-data.json)."""
+ if _is_link(entry):
+ raise OSError(errno.ELOOP, _LINK_REFUSED)
+ partial = destination.with_name(f".{destination.name}{_PARTIAL}")
+ try:
+ if entry.is_dir():
+ _copy_tree(entry, partial)
+ matches = _files(partial) == _files(entry)
+ else:
+ _copy_file(entry, partial)
+ matches = filecmp.cmp(entry, partial, shallow=False)
+ if not matches:
+ raise OSError(errno.EIO, "the copy does not match the original")
+ if os.path.lexists(destination):
+ shutil.rmtree(destination)
+ os.replace(partial, destination)
+ except OSError:
+ # The next attempt clears it too; the error that stopped the copy is
+ # the one to report.
+ with contextlib.suppress(OSError):
+ _remove(partial)
+ raise
+ fsync_dir(destination.parent)
diff --git a/app/core/takes.py b/app/core/takes.py
new file mode 100644
index 00000000..bf8014bb
--- /dev/null
+++ b/app/core/takes.py
@@ -0,0 +1,252 @@
+"""Recorded takes: where they live and what the job record says about them.
+
+A take is a folder, jobs//takes//, holding
+
+ audio.part / camera.part bytes received so far, while recording
+ parts.json upload state: next sequence number and size per kind
+ audio.raw.wav exactly what was captured (float32, capture rate)
+ audio.wav the playable take, in the stems' own format
+ camera.webm | camera.mp4 the raw camera recording, when there is one
+ take.json the sidecar
+ render.mp4 the rendered video, once asked for
+
+Takes belong to recording tracks (takes/tracks.json): a track is one mixer
+lane that owns takes and plays its active one.
+
+It lives and dies with the job folder: deleting the job removes it, putting
+the job in the Trash leaves it alone, as it does every other file.
+
+Kept free of FastAPI and ffmpeg so the registry can rebuild entries from disk
+after a restart without importing the API layer.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import secrets
+import time
+import uuid
+from pathlib import Path
+from typing import Any
+
+from app.core.config import JOB_ID_RE, JOBS_DIR, TAKE_ID_RE, TAKES_DIRNAME, TRACK_ID_RE, TRACKS_FILE
+
+logger = logging.getLogger("stemdeck.takes")
+
+CAMERA_EXTS = ("webm", "mp4")
+
+
+def take_dir(job_id: str, take_id: str, jobs_dir: Path | None = None) -> Path | None:
+ """The take's folder, resolved, or None when either id is malformed or
+ the path would leave the jobs directory. Does not check that it exists."""
+ if not JOB_ID_RE.match(job_id) or not TAKE_ID_RE.match(take_id):
+ return None
+ root = (jobs_dir or JOBS_DIR).resolve()
+ path = (root / job_id / TAKES_DIRNAME / take_id).resolve()
+ if not path.is_relative_to(root):
+ return None
+ return path
+
+
+def camera_file(folder: Path) -> Path | None:
+ """The take's camera recording, whichever container it turned out to be."""
+ for ext in CAMERA_EXTS:
+ path = folder / f"camera.{ext}"
+ if path.is_file():
+ return path
+ return None
+
+
+def take_entry(job_id: str, sidecar: dict[str, Any], has_camera: bool) -> dict[str, Any]:
+ """The record a finished take gets in job.takes, built from its sidecar.
+
+ This is what the studio reads to add the lane, so it carries everything
+ the lane needs and nothing it does not: the full sidecar stays in
+ take.json."""
+ take_id = str(sidecar["id"])
+ latency = sidecar.get("latency") or {}
+ video = sidecar.get("video") or {}
+ timeline = sidecar.get("timeline") or {}
+ camera_offset_ms = float(video.get("cameraOffsetMs") or 0.0) if has_camera else 0.0
+ # Where the camera's first frame falls in the song, as the video render
+ # places it (app/api/takes.py): the take's placement, the first frame's
+ # time in the take, less the sync nudge.
+ camera_start_sec = (
+ float(timeline.get("first_sample_song_sec") or 0.0)
+ - float(latency.get("user_nudge_ms") or 0.0) / 1000.0
+ + float(video.get("firstFrameAtMs") or 0.0) / 1000.0
+ - camera_offset_ms / 1000.0
+ )
+ base = f"/api/jobs/{job_id}/takes/{take_id}"
+ track_id = str(sidecar.get("track_id") or "")
+ return {
+ "id": take_id,
+ "track_id": track_id if TRACK_ID_RE.match(track_id) else None,
+ "label": str(sidecar.get("label") or take_id),
+ "created_at": float(sidecar.get("created_at") or 0.0),
+ "url": f"{base}/audio.wav",
+ "user_nudge_ms": float(latency.get("user_nudge_ms") or 0.0),
+ "has_camera": has_camera,
+ "camera_offset_ms": camera_offset_ms,
+ "camera_url": f"{base}/camera" if has_camera else None,
+ "camera_start_sec": round(camera_start_sec, 4) if has_camera else None,
+ }
+
+
+def recover_takes(job_dir: Path) -> list[dict[str, Any]]:
+ """Entries for every finished take on disk, oldest first.
+
+ Used when a job is rebuilt from its folder (registry restore without a
+ record), so a crash between finishing a take and persisting the registry
+ does not lose the lane: the files are the truth. A take counts as
+ finished once it has both its sidecar and its playable audio."""
+ root = job_dir / TAKES_DIRNAME
+ if not root.is_dir():
+ return []
+ entries: list[dict[str, Any]] = []
+ for folder in sorted(root.iterdir()):
+ if not folder.is_dir() or not TAKE_ID_RE.match(folder.name):
+ continue
+ sidecar_path = folder / "take.json"
+ if not sidecar_path.is_file() or not (folder / "audio.wav").is_file():
+ continue
+ # A damaged take.json costs that take, not the song: this runs while
+ # the whole library loads.
+ try:
+ sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
+ sidecar["id"] = folder.name
+ entry = take_entry(job_dir.name, sidecar, camera_file(folder) is not None)
+ except (OSError, ArithmeticError, AttributeError, RecursionError, TypeError, ValueError):
+ logger.warning("could not read %s", sidecar_path, exc_info=True)
+ continue
+ entries.append(entry)
+ entries.sort(key=lambda e: e["created_at"])
+ return entries
+
+
+# ---------------------------------------------------------------------------
+# Tracks
+
+
+def new_track_id(existing: set[str]) -> str:
+ while True:
+ track_id = "k" + secrets.token_hex(4)
+ if track_id not in existing:
+ return track_id
+
+
+def make_track(
+ track_id: str,
+ name: str,
+ order: int,
+ input_: dict | None = None,
+ kind: str = "audio",
+) -> dict[str, Any]:
+ """A new track. `kind` is "audio" or "video": a video track films its
+ takes with the camera as well (static/js/video/videoTake.js)."""
+ return {
+ "id": track_id,
+ "name": name,
+ "order": order,
+ "created_at": time.time(),
+ "active_take_id": None,
+ "input": input_,
+ "kind": kind,
+ }
+
+
+def write_json_atomic(path: Path, data: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def read_tracks(job_dir: Path) -> list[dict[str, Any]] | None:
+ """tracks.json, or None when there is none (or it cannot be read)."""
+ path = job_dir / TAKES_DIRNAME / TRACKS_FILE
+ if not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read %s", path, exc_info=True)
+ return None
+ tracks = data.get("tracks") if isinstance(data, dict) else None
+ if not isinstance(tracks, list):
+ return None
+ return [t for t in tracks if isinstance(t, dict) and TRACK_ID_RE.match(str(t.get("id")))]
+
+
+def write_tracks(job_dir: Path, tracks: list[dict[str, Any]]) -> None:
+ write_json_atomic(job_dir / TAKES_DIRNAME / TRACKS_FILE, {"v": 1, "tracks": tracks})
+
+
+def normalise_tracks(
+ tracks: list[dict[str, Any]], takes: list[dict[str, Any]]
+) -> list[dict[str, Any]]:
+ """Sorted by order, order renumbered 0..n-1, and every active_take_id a
+ finished take of its own track (else its newest one, else None)."""
+ out = sorted(
+ (dict(t) for t in tracks),
+ key=lambda t: (t.get("order", 0), t.get("created_at", 0.0)),
+ )
+ for i, track in enumerate(out):
+ track["order"] = i
+ own = [t for t in takes if t.get("track_id") == track["id"]]
+ if track.get("active_take_id") not in {t["id"] for t in own}:
+ newest = max(own, key=lambda t: t.get("created_at") or 0.0, default=None)
+ track["active_take_id"] = newest["id"] if newest else None
+ return out
+
+
+def reconcile_tracks(
+ job_dir: Path, tracks: list[dict[str, Any]] | None, takes: list[dict[str, Any]]
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
+ """Bring a job's tracks and takes into agreement, on load.
+
+ tracks.json wins over the registry record when both exist: it is written
+ first. A finished take with no track (recorded before tracks existed, or
+ whose track entry was lost) gets a track of its own, named after it and
+ playing it, and its take.json learns the track id so the next load
+ agrees. Returns (tracks, takes, changed)."""
+ on_disk = read_tracks(job_dir)
+ current = on_disk if on_disk is not None else [dict(t) for t in tracks or []]
+ known = {t["id"] for t in current}
+ new_takes: list[dict[str, Any]] = []
+ changed = on_disk is None and bool(current)
+ for entry in sorted(takes, key=lambda e: e.get("created_at") or 0.0):
+ entry = dict(entry)
+ if entry.get("track_id") not in known:
+ track_id = new_track_id(known)
+ known.add(track_id)
+ track = make_track(track_id, str(entry.get("label") or "Track"), len(current))
+ track["created_at"] = float(entry.get("created_at") or 0.0)
+ track["active_take_id"] = entry["id"]
+ current.append(track)
+ entry["track_id"] = track_id
+ _stamp_sidecar(job_dir, entry["id"], track_id)
+ changed = True
+ new_takes.append(entry)
+ normalised = normalise_tracks(current, new_takes)
+ if normalised != current:
+ changed = True
+ if changed:
+ write_tracks(job_dir, normalised)
+ return normalised, new_takes, changed
+
+
+def _stamp_sidecar(job_dir: Path, take_id: str, track_id: str) -> None:
+ path = job_dir / TAKES_DIRNAME / take_id / "take.json"
+ try:
+ sidecar = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(sidecar, dict):
+ sidecar["track_id"] = track_id
+ write_json_atomic(path, sidecar)
+ except (OSError, ValueError):
+ logger.warning("could not record the track of take %s", path, exc_info=True)
diff --git a/app/core/tls_listener.py b/app/core/tls_listener.py
index bba41af9..804d2dc1 100644
--- a/app/core/tls_listener.py
+++ b/app/core/tls_listener.py
@@ -19,7 +19,9 @@
None of this applies to server mode, where there is one listener and whoever
launched uvicorn decides its scheme. This module is started only when
-STEMDECK_HTTPS_PORT is set, which is the desktop shell's doing.
+STEMDECK_HTTPS_PORT is set, which is the desktop shell's doing, and the shell
+sets it only while network access is on (#859). While it is off the primary
+listener is on loopback alone and this one does not exist.
"""
from __future__ import annotations
@@ -132,7 +134,7 @@ async def stop() -> None:
server.should_exit = True # type: ignore[attr-defined]
try:
await asyncio.wait_for(task, timeout=3)
- except (TimeoutError, asyncio.TimeoutError):
+ except TimeoutError:
# Past the graceful window. The process is going away regardless, and
# blocking shutdown on a stuck connection is worse than a stray socket.
task.cancel()
diff --git a/app/core/versions.py b/app/core/versions.py
new file mode 100644
index 00000000..4b8fb861
--- /dev/null
+++ b/app/core/versions.py
@@ -0,0 +1,146 @@
+"""Versions of a song (#804): named choices of stems over one shared pool.
+
+A song keeps one set of stem files on disk. A version is only settings: a
+name, the stems it shows, and when it was made. Its saved mix (levels, EQ,
+master, speed and key, per mode) lives where every saved mix already lives,
+in the page's store, under a key the page derives from the song and the
+version id (static/js/versionScope.js). So a version costs a few bytes here
+and nothing on disk, and deleting one never touches a stem file.
+
+Every song has at least one version. A song made before versions existed has
+none recorded: it reads as one implicit default version (DEFAULT_VERSION_ID)
+built from its import selection, and nothing is written until a version is
+created, renamed, deleted or chosen. The default's mix is the song's mix as it
+was before versions, under the same store keys, so nothing moves.
+
+Everything read back from the registry goes through clean_versions: a damaged
+or hand-edited record loses only the entries that are not a version, never the
+song.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import sys
+import time
+import unicodedata
+import uuid
+from typing import Any
+
+from app.core.config import STEM_NAMES
+
+logger = logging.getLogger("stemdeck.versions")
+
+# The id of the version every song has before any other is made. Its saved
+# mix is the one under the song's own store keys (no version suffix).
+DEFAULT_VERSION_ID = "main"
+# A version id: short, lowercase, safe in a URL path and a store key.
+VERSION_ID_RE = re.compile(r"^[a-z0-9]{1,16}\Z")
+# A song with more versions than this is a script, not a person.
+MAX_VERSIONS = 32
+VERSION_NAME_MAX_CHARS = 60
+
+
+def clean_name(value: Any) -> str | None:
+ """A version name as kept: a string, trimmed, control characters removed,
+ at most VERSION_NAME_MAX_CHARS long. None when nothing is left."""
+ if not isinstance(value, str):
+ return None
+ text = "".join(" " if unicodedata.category(ch)[0] == "C" else ch for ch in value)
+ text = " ".join(text.split())[:VERSION_NAME_MAX_CHARS].strip()
+ return text or None
+
+
+def clean_stems(value: Any) -> list[str]:
+ """The base stems of a choice, in STEM_NAMES order, each once. Unknown
+ names are dropped."""
+ if not isinstance(value, list):
+ return []
+ wanted = {s for s in value if isinstance(s, str)}
+ return [s for s in STEM_NAMES if s in wanted]
+
+
+def _finite_time(value: Any) -> float:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return 0.0
+ # Not math.isfinite: it raises OverflowError for an int too big for a float.
+ return float(value) if abs(value) <= sys.float_info.max else 0.0
+
+
+def _clean_one(raw: Any) -> dict[str, Any] | None:
+ if not isinstance(raw, dict):
+ return None
+ vid = raw.get("id")
+ if not isinstance(vid, str) or not VERSION_ID_RE.match(vid):
+ return None
+ return {
+ "id": vid,
+ "name": clean_name(raw.get("name")),
+ "stems": clean_stems(raw.get("stems")) or list(STEM_NAMES),
+ "created_at": _finite_time(raw.get("created_at")),
+ }
+
+
+def clean_versions(value: Any) -> list[dict[str, Any]] | None:
+ """The versions recorded on a song, made safe, or None when it has none
+ recorded (the implicit default then applies). Never raises."""
+ if value is None:
+ return None
+ if not isinstance(value, list):
+ logger.warning("dropping a versions value that is not a list")
+ return None
+ out: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for raw in value[:MAX_VERSIONS]:
+ version = _clean_one(raw)
+ if version is None or version["id"] in seen:
+ logger.warning("dropping an unreadable version entry")
+ continue
+ seen.add(version["id"])
+ out.append(version)
+ return out or None
+
+
+def default_version(selected_stems: Any, created_at: Any) -> dict[str, Any]:
+ """The version a song has before any is made: its import selection."""
+ return {
+ "id": DEFAULT_VERSION_ID,
+ "name": None,
+ "stems": clean_stems(selected_stems) or list(STEM_NAMES),
+ "created_at": _finite_time(created_at),
+ }
+
+
+def versions_of(job: Any) -> list[dict[str, Any]]:
+ """Every version of `job`, never empty: the recorded ones, or the
+ implicit default when none is recorded. Copies, safe to change."""
+ recorded = clean_versions(getattr(job, "versions", None))
+ if recorded:
+ return [dict(v, stems=list(v["stems"])) for v in recorded]
+ return [default_version(getattr(job, "selected_stems", None), getattr(job, "created_at", 0))]
+
+
+def active_of(job: Any, versions: list[dict[str, Any]] | None = None) -> str:
+ """The id of the version that opens with the song: the one chosen last,
+ or the first when that one is gone."""
+ versions = versions if versions is not None else versions_of(job)
+ active = getattr(job, "active_version", None)
+ ids = [v["id"] for v in versions]
+ return active if isinstance(active, str) and active in ids else ids[0]
+
+
+def new_version_id(existing: set[str]) -> str:
+ while True:
+ vid = uuid.uuid4().hex[:8]
+ if vid not in existing and vid != DEFAULT_VERSION_ID:
+ return vid
+
+
+def make_version(existing: list[dict[str, Any]], name: str | None, stems: list[str]) -> dict:
+ return {
+ "id": new_version_id({v["id"] for v in existing}),
+ "name": clean_name(name),
+ "stems": clean_stems(stems) or list(STEM_NAMES),
+ "created_at": time.time(),
+ }
diff --git a/app/core/vocal_split_outcome.py b/app/core/vocal_split_outcome.py
new file mode 100644
index 00000000..11863d84
--- /dev/null
+++ b/app/core/vocal_split_outcome.py
@@ -0,0 +1,54 @@
+"""Why a Lead + Backing split did not make its two stems (#832).
+
+The split is a best-effort second pass over a finished song, so its failure
+never fails the song: the Vocals stem stays as it was. That used to be the
+whole story, and the person who asked for Lead + Backing got one Vocals lane
+and no word about it. The job now carries one of these codes, and the client
+says what happened from it in the person's own language.
+
+The messages below are the API's plain-English fallback for any client that
+has no table of its own. They are fixed strings on purpose: nothing from the
+worker's stderr, no path and no traceback ever reaches a client through them.
+"""
+
+from __future__ import annotations
+
+DEPENDENCY_MISSING = "dependency_missing"
+MODEL_UNAVAILABLE = "model_unavailable"
+OUT_OF_MEMORY = "out_of_memory"
+WORKER_FAILED = "worker_failed"
+TIMEOUT = "timeout"
+CANCELLED = "cancelled"
+# The app stopped while the split ran, so it never finished.
+INTERRUPTED = "interrupted"
+# This build has no split at all: audio-separator is not installed on Intel
+# macOS (pyproject.toml), so no update or retry brings it.
+UNSUPPORTED_PLATFORM = "unsupported_platform"
+
+MESSAGES: dict[str, str] = {
+ DEPENDENCY_MISSING: (
+ "A part StemDeck needs for Lead + Backing did not load on this install. "
+ "Update StemDeck, then try again."
+ ),
+ MODEL_UNAVAILABLE: (
+ "The Lead + Backing model could not be downloaded or loaded. "
+ "Check the internet connection, then try again."
+ ),
+ OUT_OF_MEMORY: (
+ "The computer ran out of memory during the split. Close other apps, then try again."
+ ),
+ WORKER_FAILED: "The split stopped with an error. Try again, or check the logs.",
+ TIMEOUT: "The split stopped responding and was stopped. Try again.",
+ CANCELLED: "The split was cancelled.",
+ INTERRUPTED: "StemDeck closed before the split finished. Try again.",
+ UNSUPPORTED_PLATFORM: "Lead + Backing is not available on Macs with an Intel processor.",
+}
+
+CODES = frozenset(MESSAGES)
+
+
+def message_for(code: str | None) -> str | None:
+ """The safe sentence for ``code``, or None when there is no failure."""
+ if code is None:
+ return None
+ return MESSAGES.get(code, MESSAGES[WORKER_FAILED])
diff --git a/app/main.py b/app/main.py
index 238d576b..10310e5f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -5,9 +5,11 @@
import io
import json
import logging
+import math
import mimetypes
import os
import re
+import shutil
import signal
import socket
import time
@@ -23,14 +25,17 @@
from fastapi.staticfiles import StaticFiles
from app.api.router import router
-from app.core import tls_listener
+from app.core import csrf, tls_listener
from app.core.compression import COMPRESS_LEVEL, MINIMUM_SIZE, TextGZipMiddleware
from app.core.config import (
ACOUSTID_CHECK_KEY,
+ ALLOWED_HOSTS,
+ BIND_HOST,
DEMUCS_MODEL,
DISCOGS_CHECK_TOKEN,
FFMPEG_BIN,
HTTPS_PORT,
+ IDLE_UNLOAD_MINUTES_MAX,
JOBS_DIR,
LOGS_DIR,
SSL_CERTFILE,
@@ -39,9 +44,13 @@
available_torch_devices,
configure_portable_environment,
ensure_runtime_dirs,
+ ffmpeg_executable,
+ torch_gpu_backend,
torch_load_error,
)
+from app.core.device_reason import cpu_reason, device_diagnostics, refresh_nvidia_gpu
from app.core.logging_setup import configure_logging
+from app.core.models import JobBusy
from app.core.process import process_exists as _process_exists
from app.core.redact import redact
from app.core.registry import all_jobs as registry_all_jobs
@@ -60,33 +69,42 @@
get_allow_network,
get_auto_delete_days,
get_auto_delete_jobs,
+ get_auto_lyrics,
get_auto_sections,
get_cookies_file,
get_demucs_device,
get_demucs_device_choice,
get_export_sample_rate,
+ get_idle_unload_minutes,
get_jobs_dir,
get_max_duration_sec,
get_playlist_max_items,
get_port,
get_separation_quality,
+ get_stem_format,
+ get_stem_mp3_kbps,
get_transcribe_lyrics,
+ get_video_encoder,
get_video_max_height,
set_acoustid_api_key,
set_allow_network,
set_auto_delete_days,
set_auto_delete_jobs,
+ set_auto_lyrics,
set_auto_sections,
set_cookies_file,
set_demucs_device,
set_discogs_token,
set_export_sample_rate,
+ set_idle_unload_minutes,
set_jobs_dir,
set_max_duration_sec,
set_playlist_max_items,
set_port,
set_separation_quality,
+ set_stem_storage,
set_transcribe_lyrics,
+ set_video_encoder,
set_video_max_height,
)
from app.core.stems_location import (
@@ -94,11 +112,20 @@
abandon_relocation,
begin_relocation,
directory_size,
+ is_relocating,
move_library,
+ remove_moved,
validate_target,
)
-from app.pipeline.collect import sweep_failed_jobs, sweep_old_jobs
+from app.pipeline.audio_encoders import (
+ MIN_FFMPEG_VERSION,
+ export_formats,
+ ffmpeg_too_old,
+ transpose_export,
+)
+from app.pipeline.collect import sweep_crash_leftovers, sweep_failed_jobs, sweep_old_jobs
from app.pipeline.sections import sweep_orphaned_workspaces as sweep_orphaned_section_workspaces
+from app.pipeline.vocal_split_status import capability as vocal_split_capability
# Set the stemdeck logger level (Python's default root level of WARNING would
# silently drop every logger.info(...) call) and attach the rotating file log
@@ -107,7 +134,10 @@
# STEMDECK_LOG_LEVEL=DEBUG) to see them.
configure_logging()
logging.getLogger("stemdeck").info(
- "demucs config: model=%s device=%s", DEMUCS_MODEL, get_demucs_device()
+ "demucs config: model=%s device=%s gpu=%s",
+ DEMUCS_MODEL,
+ get_demucs_device(),
+ torch_gpu_backend(available_torch_devices()),
)
configure_portable_environment()
@@ -208,6 +238,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
qt = jobqueue.start_worker()
_background_tasks.add(qt)
qt.add_done_callback(_background_tasks.discard)
+ # Unloads the separation worker and the beat model once they have been
+ # idle for the Settings' minutes (#975). Cancelled first at shutdown.
+ from app.pipeline import idle_unload
+
+ idle_task = asyncio.create_task(idle_unload.run())
+ _background_tasks.add(idle_task)
+ idle_task.add_done_callback(_background_tasks.discard)
# Jobs that were queued or in flight when the process last died. restore()
# already put them back to "queued"; this puts them back in the queue.
#
@@ -221,6 +258,48 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await asyncio.to_thread(sweep_orphaned_section_workspaces, JOBS_DIR)
except Exception:
_log.exception("could not sweep orphaned section workspaces")
+ # A take render the last process was killed in the middle of left its
+ # temp file beside the take (take_render.run_video_render): nothing is
+ # rendering yet, so any such file is an orphan.
+ try:
+ from app.pipeline.take_render import sweep_render_temps
+
+ await asyncio.to_thread(sweep_render_temps, JOBS_DIR)
+ except Exception:
+ _log.exception("could not sweep stale render files")
+ # The same for a stem fill, a vocal split decode, a stem write, an export
+ # render and a stems zip.
+ try:
+ from app.api.stems import _CLICK_CACHE_DIR, _MIXDOWN_CACHE_DIR
+
+ await asyncio.to_thread(
+ sweep_crash_leftovers, JOBS_DIR, (_MIXDOWN_CACHE_DIR, _CLICK_CACHE_DIR)
+ )
+ except Exception:
+ _log.exception("could not sweep crash leftovers")
+
+ # An FFmpeg older than 5.1 cannot run any export (app/pipeline/audio_encoders.py).
+ # The server still starts, so imports and playback work and the page can
+ # say the same thing (/api/health ffmpeg_too_old).
+ too_old = await asyncio.to_thread(ffmpeg_too_old)
+ if too_old:
+ minimum = ".".join(map(str, MIN_FFMPEG_VERSION))
+ _log.error(
+ "FFmpeg %s at %s is too old: StemDeck needs FFmpeg %s or newer, and every "
+ "export and master will fail with this one. Install FFmpeg %s or newer, or "
+ "set STEMDECK_FFMPEG to one, then restart StemDeck.",
+ too_old,
+ shutil.which(ffmpeg_executable()) or ffmpeg_executable(),
+ minimum,
+ minimum,
+ )
+
+ # The first nvidia-smi answer, asked now so opening Settings does not wait
+ # for it (app/core/device_reason.py, #247). Not awaited: the shell is
+ # polling /api/health to learn that this server is up.
+ gt = asyncio.create_task(asyncio.to_thread(refresh_nvidia_gpu))
+ _background_tasks.add(gt)
+ gt.add_done_callback(_background_tasks.discard)
resumed = take_pending_resume()
if resumed:
@@ -251,15 +330,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
await tls_listener.stop()
# Stop taking new work. Deliberately not cancelling the in-flight job: it is
- # blocked in asyncio.to_thread, which cannot be cancelled, so it dies with
- # the process exactly as it did before the queue existed.
+ # blocked in asyncio.to_thread, which cannot be cancelled. First, though:
+ # the worker kill below ends a separation, and this is what makes the
+ # runner keep that job to resume rather than fail it.
jobqueue.request_stop()
+ # No idle unload from here on: the kill below is the last word.
+ idle_task.cancel()
+ # Running take renders (ffmpeg processes) stop with the server.
+ from app.api.takes import cancel_all_renders
+
+ cancel_all_renders()
# Tear down the persistent demucs worker (#309) so a clean shutdown never
# leaves it as an orphaned process -- it has no parent-death watchdog of
- # its own, unlike the desktop backend itself.
- from app.pipeline.separate import _kill_worker
+ # its own, unlike the desktop backend itself. Waits for an idle unload
+ # that was already stopping it.
+ from app.pipeline.separate import shutdown_worker
- _kill_worker()
+ await asyncio.to_thread(shutdown_worker)
# Phones hitting the self-hosted server URL get the mobile UI; everything
@@ -314,6 +401,27 @@ def health() -> dict[str, object]:
"status": "ok",
"version": app_version(),
"ffmpeg_configured": FFMPEG_BIN.is_file(),
+ # {"wav", "mp3", "flac", "ogg": whether this ffmpeg can encode it}, or
+ # null when that is unknown. OGG is false on an ffmpeg without
+ # libvorbis, which the export menu then shows as unavailable (#824).
+ # Probed once per ffmpeg binary, so this stays a cheap poll.
+ "export_formats": export_formats(),
+ # Whether this ffmpeg has the rubberband filter a transposed export
+ # needs, or null when there is no ffmpeg to ask. False on the macOS
+ # download and Homebrew's plain ffmpeg, and the export menu then says
+ # so before a transposed mix is exported, rather than the export
+ # failing with a 422 at the end (#858). Probed once per binary.
+ "transpose_export": transpose_export(),
+ # The version of this ffmpeg when it is older than 5.1, which cannot
+ # run any export, else null. The page then says so. Read from the
+ # version line cached per binary, like transpose_export.
+ "ffmpeg_too_old": ffmpeg_too_old(),
+ # {"available": bool, "reason": code or None}: whether Lead + Backing
+ # can be made on this install. False only after setup or a real split
+ # found a dependency missing on these exact packages, which the
+ # Vocals choice then shows as unavailable before anyone picks it
+ # (#832). Read from a small file, so this stays a cheap poll.
+ "vocal_split": vocal_split_capability(),
"demucs_model": DEMUCS_MODEL,
"demucs_device": get_demucs_device(),
# Who is answering. The desktop shell spawns this backend and then polls
@@ -350,6 +458,8 @@ def _is_lan_ipv4(ip: str) -> bool:
def _settings_payload() -> dict[str, object]:
+ devices = available_torch_devices()
+ why = cpu_reason()
return {
"allow_network": get_allow_network(),
# Off unless the user asked for it. The days value is published even
@@ -361,6 +471,9 @@ def _settings_payload() -> dict[str, object]:
# job and produces suggestions rather than ground truth, so the user
# decides whether to pay for it.
"auto_sections": get_auto_sections(),
+ # Lyrics on import (LRCLIB and the transcript). Off until the user
+ # turns Detect lyrics on; kept until changed.
+ "auto_lyrics": get_auto_lyrics(),
"auto_delete_days_min": AUTO_DELETE_DAYS_MIN,
"auto_delete_days_max": AUTO_DELETE_DAYS_MAX,
"max_duration_sec": get_max_duration_sec(),
@@ -373,9 +486,20 @@ def _settings_payload() -> dict[str, object]:
"video_max_height": get_video_max_height(),
"export_sample_rate": get_export_sample_rate(),
"separation_quality": get_separation_quality(),
+ # Idle minutes before the separation worker and the beat model unload
+ # (#975), 0 for never; the ceiling so the field can say it.
+ "idle_unload_minutes": get_idle_unload_minutes(),
+ "idle_unload_minutes_max": IDLE_UNLOAD_MINUTES_MAX,
+ # How new songs' stems are stored (#783). Read when a job finishes;
+ # songs already in the library keep their own format.
+ "stem_format": get_stem_format(),
+ "stem_mp3_kbps": get_stem_mp3_kbps(),
# Whisper lyrics for a job no lookup found any for: "auto" (on a GPU
# only) | "on" | "off".
"transcribe_lyrics": get_transcribe_lyrics(),
+ # The take render's H.264 encoder: "auto" | "cpu" | an encoder name.
+ # GET/POST /api/video-encoders list and test what this machine has.
+ "video_encoder": get_video_encoder(),
# Absent unless the user set one. Only the path is exposed, never the
# file's contents -- those are the user's YouTube session.
"cookies_file": get_cookies_file(),
@@ -393,10 +517,19 @@ def _settings_payload() -> dict[str, object]:
# available lets the UI gray out devices this machine can't use.
"demucs_device": get_demucs_device_choice(),
"demucs_device_resolved": get_demucs_device(),
- "demucs_devices_available": available_torch_devices(),
+ "demucs_devices_available": devices,
+ # What drives "cuda": "cuda" (NVIDIA) | "rocm" (AMD) | None. A ROCm
+ # build serves an AMD GPU as "cuda", so the device alone cannot tell
+ # the user which kind of GPU separation runs on (#825).
+ "gpu_backend": torch_gpu_backend(devices),
# Set when torch is installed but cannot load, which stops separation
# on every device (#730). Read after the probe above refreshed it.
"torch_error": torch_load_error(),
+ # Why the NVIDIA GPU is not used, as one code (app/core/device_reason.py),
+ # or None when it is in use or the machine has none (#247). The
+ # diagnostics line is what Copy GPU details puts on the clipboard.
+ "demucs_device_why": why,
+ "device_diagnostics": device_diagnostics(why),
}
@@ -425,7 +558,18 @@ def get_settings(request: Request) -> dict[str, object]:
# Show the port the server is actually running on rather than the stored
# preference (which only takes effect on the next restart) -- so the field
# reflects reality. Editing it still saves the preference via POST.
- return {**_settings_payload(), "port": port, "lan_addresses": addresses}
+ #
+ # The same for the network switch on desktop: allow_network is the saved
+ # choice, network_listening is whether this process listens beyond
+ # loopback, which only changes when the shell restarts it (#859). None
+ # when the shell did not start this server.
+ listening = None if BIND_HOST is None else not _is_loopback(BIND_HOST)
+ return {
+ **_settings_payload(),
+ "port": port,
+ "lan_addresses": addresses,
+ "network_listening": listening,
+ }
# The detail for a key AcoustID refuses, which the Settings field tells apart
@@ -435,6 +579,16 @@ def get_settings(request: Request) -> dict[str, object]:
DISCOGS_TOKEN_REFUSED = "Discogs does not accept this token"
+def _whole_minutes(value: object) -> bool:
+ """A JSON number with no fraction, 0 or more. Not a bool (a JSON true is
+ an int in Python) and not a string, "5" included."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return False
+ if isinstance(value, float) and not (math.isfinite(value) and value.is_integer()):
+ return False
+ return value >= 0
+
+
@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
@@ -444,23 +598,37 @@ async def update_settings(request: Request) -> dict[str, object]:
body = await request.json()
except Exception:
body = {}
+ if not isinstance(body, dict):
+ body = {}
if "allow_network" in body:
set_allow_network(bool(body["allow_network"]))
if "auto_delete_jobs" in body:
set_auto_delete_jobs(bool(body["auto_delete_jobs"]))
if "auto_sections" in body:
set_auto_sections(bool(body["auto_sections"]))
+ if "auto_lyrics" in body:
+ set_auto_lyrics(bool(body["auto_lyrics"]))
+ if "idle_unload_minutes" in body and not _whole_minutes(body["idle_unload_minutes"]):
+ # Refused rather than clamped: true would be stored as 1 minute and -1
+ # as 0, which turns the unload off, so a typo must not change it.
+ raise HTTPException(
+ status_code=422,
+ detail="idle_unload_minutes must be a whole number of minutes, 0 or more",
+ )
for key, setter in (
("auto_delete_days", set_auto_delete_days),
("max_duration_sec", set_max_duration_sec),
("playlist_max_items", set_playlist_max_items),
("video_max_height", set_video_max_height),
("port", set_port),
+ ("idle_unload_minutes", set_idle_unload_minutes),
):
if key in body:
try:
setter(int(body[key]))
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: JSON Infinity or 1e400 parses to a float inf,
+ # and int(inf) raises it, which used to answer 500.
raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None
if "export_sample_rate" in body:
try:
@@ -472,7 +640,7 @@ async def update_settings(request: Request) -> dict[str, object]:
try:
set_cookies_file(body["cookies_file"])
except ValueError as e:
- # "cookies file not found" / "not readable" -- both safe to show.
+ # One fixed sentence for every refusal, so safe to show.
raise HTTPException(status_code=422, detail=str(e)) from None
except (TypeError, OSError):
raise HTTPException(status_code=422, detail="invalid cookies file") from None
@@ -523,12 +691,28 @@ async def update_settings(request: Request) -> dict[str, object]:
set_separation_quality(str(body["separation_quality"]))
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
+ if "stem_format" in body or "stem_mp3_kbps" in body:
+ try:
+ # Both checked before either is kept: one bad value changes nothing.
+ set_stem_storage(body.get("stem_format"), body.get("stem_mp3_kbps"))
+ except ValueError as e:
+ # The messages list the whitelist; they never echo the value.
+ raise HTTPException(status_code=422, detail=str(e)) from None
if "transcribe_lyrics" in body:
try:
set_transcribe_lyrics(str(body["transcribe_lyrics"]))
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
- return _settings_payload()
+ if "video_encoder" in body:
+ if not isinstance(body["video_encoder"], str):
+ raise HTTPException(status_code=422, detail="video_encoder must be a string")
+ try:
+ set_video_encoder(body["video_encoder"])
+ except ValueError as e:
+ # The message lists the whitelist; it never echoes the value.
+ raise HTTPException(status_code=422, detail=str(e)) from None
+ # Off the event loop: the payload can run nvidia-smi (app/core/device_reason.py).
+ return await asyncio.to_thread(_settings_payload)
_ACTIVE_JOB_STATUSES = ("queued", "downloading", "analyzing", "separating", "processing")
@@ -602,6 +786,8 @@ async def set_stems_location(request: Request) -> dict[str, object]:
body = await request.json()
except Exception:
body = {}
+ if not isinstance(body, dict):
+ body = {}
raw = body.get("path")
if not isinstance(raw, str):
raise HTTPException(status_code=422, detail="path is required")
@@ -618,6 +804,8 @@ async def set_stems_location(request: Request) -> dict[str, object]:
status_code=409,
detail="Finish or cancel the imports in the queue before moving the stems folder",
)
+ if any(j.post_hoc_running() for j in registry_all_jobs().values()):
+ raise JobBusy()
target = validate_target(Path(raw), JOBS_DIR)
except StemsLocationError as e:
abandon_relocation()
@@ -637,6 +825,10 @@ async def set_stems_location(request: Request) -> dict[str, object]:
raise HTTPException(status_code=500, detail="Could not move the stems folder") from None
_, persisted = set_jobs_dir(str(target))
+ if persisted:
+ # Not before: until the new location is saved, a restart reads the old
+ # folder, so it has to stay whole.
+ await asyncio.to_thread(remove_moved, JOBS_DIR, result.copied)
_log.info(
"stems library moved to %s (%d entries, %d bytes, persisted=%s)",
target,
@@ -667,7 +859,8 @@ async def set_stems_location(request: Request) -> dict[str, object]:
@app.post("/api/reset", tags=["settings"])
def reset_app_data() -> dict[str, object]:
"""Factory reset (Settings -> General -> "Reset app data"): delete every
- job directory and the registry, so no old work session can resurface
+ job directory and the registry (only StemDeck's own entries: see
+ registry.reset_all), so no old work session can resurface
across package reinstalls -- on desktop, the runtime state that actually
persists (~/Documents/StemDeck, not the extracted package's own bundled
data/ folder) survives a fresh install otherwise. The browser-side
@@ -685,20 +878,31 @@ def reset_app_data() -> dict[str, object]:
active = [j for j in registry_all_jobs().values() if j.status in _ACTIVE_JOB_STATUSES]
if active:
raise HTTPException(status_code=409, detail="cannot reset while a job is in progress")
+ if any(j.post_hoc_running() for j in registry_all_jobs().values()):
+ raise JobBusy()
# Clear the queue alongside the registry, or the worker would keep popping
# ids that no longer resolve and the queue view would report ghosts.
+ from app.api.takes import forget_job_locks
from app.pipeline import jobqueue
+ from app.pipeline.backing import forget_job as forget_backing
jobqueue.clear()
+ job_ids = list(registry_all_jobs())
# Anything that could not be removed is reported rather than swallowed: the
# frontend used to take an unconditional {"ok": true} as licence to wipe its
# own deletion tombstone, and any surviving directory was then re-adopted on
# the next start with nothing left to suppress it (#521). reset_all also
# records the survivors server-side, so they stay deleted regardless.
- undeleted = reset_registry(JOBS_DIR)
+ undeleted, skipped = reset_registry(JOBS_DIR)
+ for job_id in job_ids:
+ forget_backing(job_id)
+ forget_job_locks(job_id)
if undeleted:
_log.warning("reset left %d entries on disk: %s", len(undeleted), undeleted)
- return {"ok": True, "undeleted": len(undeleted)}
+ if skipped:
+ # Not StemDeck's: the jobs folder is shared with something else.
+ _log.info("reset left %d entries that are not StemDeck's", len(skipped))
+ return {"ok": True, "undeleted": len(undeleted), "skipped": len(skipped)}
@app.get("/api/registry", tags=["settings"])
@@ -804,15 +1008,22 @@ def _line_time(line: str) -> float | None:
return None
+def _tail_bytes(path: Path, limit: int) -> tuple[bytes, bool]:
+ """The last `limit` bytes of a file from a line start, without reading all
+ of it, and whether anything before them was left out."""
+ size = path.stat().st_size
+ with path.open("rb") as f:
+ if size <= limit:
+ return f.read(limit), False
+ f.seek(size - limit)
+ f.readline() # discard the partial first line
+ return f.read(limit), True
+
+
def _tail_lines(path: Path) -> list[str]:
- """Last _LOG_TAIL_BYTES of a file as lines, without reading all of it."""
+ """Last _LOG_TAIL_BYTES of a file as lines."""
try:
- size = path.stat().st_size
- with path.open("rb") as f:
- if size > _LOG_TAIL_BYTES:
- f.seek(size - _LOG_TAIL_BYTES)
- f.readline() # discard the partial first line
- raw = f.read()
+ raw, _cut = _tail_bytes(path, _LOG_TAIL_BYTES)
except OSError:
return []
return raw.decode("utf-8", errors="replace").splitlines()
@@ -857,7 +1068,7 @@ def get_log_tail(view: str, minutes: int = 60) -> PlainTextResponse:
body = (
f"No entries in the last {window} minutes.\n"
if lines
- else f"No log file yet at {logs_dir / files[0]}.\n"
+ else f"No log file yet ({files[0]}).\n"
)
return PlainTextResponse(body, media_type="text/plain; charset=utf-8")
@@ -877,14 +1088,23 @@ def get_log_tail(view: str, minutes: int = 60) -> PlainTextResponse:
return PlainTextResponse(body, media_type="text/plain; charset=utf-8")
+# Per file in the zip. Above the application log's 5 MB rotation, so only
+# backend.log or setup.log, which rotate at launch and not by size, are cut.
+_LOG_ZIP_FILE_BYTES = 8_000_000
+
+
@app.get("/api/logs.zip", tags=["settings"])
def download_logs_zip() -> StreamingResponse:
"""Bundle the log files into a zip for support and bug reports.
Only the known log names are read, never whatever else happens to be in the
directory: the set is fixed above, so a stray file dropped in LOGS_DIR can
- never be swept into a download. Built in memory -- these are capped at 5 MB
- x 3 backups plus two small desktop logs, so the whole set is bounded.
+ never be swept into a download. Built in memory, bounded by
+ _LOG_ZIP_FILE_BYTES per file.
+
+ Redacted like the Logs viewer, because the Settings text offers this zip
+ for attaching to a public bug report. Decoded with surrogateescape so a
+ byte that is not UTF-8 goes back into the file unchanged.
"""
logs_dir = LOGS_DIR.resolve()
buf = io.BytesIO()
@@ -895,15 +1115,21 @@ def download_logs_zip() -> StreamingResponse:
try:
if not path.is_file():
continue
- zf.writestr(name, path.read_bytes())
- written += 1
+ raw, cut = _tail_bytes(path, _LOG_ZIP_FILE_BYTES)
except OSError:
_log.warning("could not add %s to the log bundle", name, exc_info=True)
+ continue
+ text = redact(raw.decode("utf-8", errors="surrogateescape"))
+ if cut:
+ text = "[... earlier part of this log not included ...]\n" + text
+ zf.writestr(name, text.encode("utf-8", errors="surrogateescape"))
+ written += 1
if written == 0:
# An empty zip is a confusing download; say so inside it instead.
+ # No folder named: on desktop it is inside the user's home.
zf.writestr(
"README.txt",
- f"No log files were found in {logs_dir}.\n"
+ "No log files were found in the StemDeck logs folder.\n"
"Logging starts on the first message after the app launches.\n",
)
@@ -1007,6 +1233,82 @@ async def limit_json_body_size(request: Request, call_next):
return await call_next(request)
+# After a stems folder move, JOBS_DIR in this process still names the folder the
+# library left until the restart (app/core/stems_location.py). A favourite, a
+# Trash, an edit or an import accepted now lands there and is gone on the next
+# start, when the moved registry wins. So every write is refused with a code the
+# UI translates, except the settings, which live outside the library, and the
+# lookups that POST only to carry a body. A second move is refused too: it would
+# move nothing out of the emptied folder, and a failed one would reopen the door.
+_WRITES_ALLOWED_DURING_MOVE = frozenset({"/api/settings", "/api/search", "/api/playlist/preview"})
+
+
+@app.middleware("http")
+async def refuse_writes_until_restart(request: Request, call_next):
+ path = request.url.path
+ if (
+ is_relocating()
+ and request.method in csrf.UNSAFE_METHODS
+ and path.startswith("/api/")
+ and path not in _WRITES_ALLOWED_DURING_MOVE
+ ):
+ return JSONResponse(
+ {
+ "detail": "Restart StemDeck to finish moving your stems folder. "
+ "Until then, changes are not saved.",
+ "code": "restart_required",
+ },
+ status_code=409,
+ )
+ return await call_next(request)
+
+
+# A delete, a library move or a reset refused while a stem fill or a vocal split
+# runs. Its own code, which the UI translates, so it never reads as the restart
+# refusal above.
+@app.exception_handler(JobBusy)
+async def refuse_while_busy(_request: Request, _exc: JobBusy) -> JSONResponse:
+ return JSONResponse(
+ {
+ "detail": "A song is still being separated (missing stems or Lead + Backing). "
+ "Try again when it has finished.",
+ "code": "job_busy",
+ },
+ status_code=409,
+ )
+
+
+# Cross-site guard (app/core/csrf.py has the reasoning and every rule). Inside
+# the network and secure-origin gates, so a device those gates refuse gets
+# their explanation rather than this one; outside the headers middleware, so a
+# refusal still carries the CSP. Network access is read per request because
+# the Settings toggle changes it live.
+_HOST_REFUSED_MESSAGE = """StemDeck does not answer to this address.
+
+Open it at http://localhost or the machine's own IP address instead. Behind a
+reverse proxy or under another name, add that name to STEMDECK_ALLOWED_HOSTS
+(a comma-separated list) and restart."""
+
+
+@app.middleware("http")
+async def cross_site_guard(request: Request, call_next):
+ refusal = csrf.check_request(
+ request.method,
+ request.url.path,
+ request.headers,
+ allow_network=get_allow_network(),
+ desktop=_is_desktop_shell(),
+ extra_hosts=ALLOWED_HOSTS,
+ local_ips=_local_ips(),
+ )
+ if refusal is not None:
+ _log.info("refused %s %s: %s", request.method, request.url.path, refusal.reason)
+ if refusal.detail == csrf.REFUSED_HOST:
+ return PlainTextResponse(_HOST_REFUSED_MESSAGE, status_code=refusal.status)
+ return JSONResponse({"detail": refusal.detail}, status_code=refusal.status)
+ return await call_next(request)
+
+
@app.middleware("http")
async def security_and_cache_headers(request: Request, call_next):
response = await call_next(request)
@@ -1147,6 +1449,11 @@ async def secure_origin_gate(request: Request, call_next):
# 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.
+#
+# In server mode this is the whole control. On desktop the shell also binds
+# loopback only while the setting is off (#859), so this is the second line:
+# it covers the moment between saving the choice and the restart that applies
+# it, and anything that starts the desktop backend on every interface.
@app.middleware("http")
async def network_gate(request: Request, call_next):
if not get_allow_network():
diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py
index 03a81820..7f95f648 100644
--- a/app/pipeline/analyze.py
+++ b/app/pipeline/analyze.py
@@ -1,11 +1,13 @@
from __future__ import annotations
import logging
+import math
+import re
import subprocess
from pathlib import Path
-from app.core.config import JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable
-from app.core.models import Job, JobCancelled, _set
+from app.core.config import EBUR128_FRAMELOG, JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable
+from app.core.models import LOUDNESS_VERSION, Job, JobCancelled, _set
from app.pipeline.cancel import check_cancel, run_registered
logger = logging.getLogger("stemdeck.analyze")
@@ -224,34 +226,82 @@ def _detect_key(
return label, scale_name, round(confidence * 100)
-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:
+# The loudness card's numbers come from ffmpeg's ebur128 filter: ITU-R BS.1770-4
+# integrated loudness (gated, K-weighted, all channels, the file's own rate) and
+# the 4x oversampled true peak, over the whole track. It is the same measurement
+# pyloudnorm gives on the stereo mix (within 0.01 LU on the measured songs) and
+# a streaming service's meter, at about 2 s a song with no new dependency. The
+# first version of this card measured a 22 kHz MONO buffer cut at 180 s: a mono
+# sum counts as one channel, so it read about 4 LU too low.
+_I_RE = re.compile(r"^\s*I:\s*(-?\d+(?:\.\d+)?|-?inf)\s*LUFS", re.MULTILINE)
+_PEAK_RE = re.compile(r"^\s*Peak:\s*(-?\d+(?:\.\d+)?|-?inf)\s*dBFS", re.MULTILINE)
+# ebur128 reports its floor, not -inf, for a track that is all silence.
+_LUFS_FLOOR = -69.9
+
+
+def parse_ebur128_summary(text: str) -> tuple[float | None, float | None]:
+ """(integrated LUFS, true peak dBTP) from ffmpeg's ebur128 summary, or None
+ for either that is missing, silent or not a number."""
+ lufs_all = _I_RE.findall(text)
+ peak_all = _PEAK_RE.findall(text)
+ lufs = _finite(lufs_all[-1]) if lufs_all else None
+ peak = _finite(peak_all[-1]) if peak_all else None
+ if lufs is not None and lufs <= _LUFS_FLOOR:
+ lufs = None
+ return lufs, peak
+
+
+def _finite(raw: str) -> float | None:
+ value = float(raw)
+ return value if math.isfinite(value) else None
+
+
+def measure_loudness(
+ sources: list[Path], job: Job | None = None
+) -> tuple[float | None, float | None]:
+ """Integrated loudness (LUFS, BS.1770-4 gated) and true peak (dBTP) of the
+ whole stereo mix, over the full track. One file is measured as it is; several
+ (a job whose source was not kept: its stems) are summed first, which is the
+ mix. Returns (None, None) when ffmpeg is missing or fails, or the audio is
+ silent: these are display fields, so a failure degrades to "no value".
+
+ With a `job` the process is registered so a cancel stops it (#748); the
+ caller checks the cancel flag when this returns."""
+ if not sources:
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
+ cmd: list[str] = [ffmpeg_executable(), "-hide_banner", "-nostdin", "-vn"]
+ for source in sources:
+ cmd += ["-i", str(source)]
+ meter = f"ebur128=peak=true:framelog={EBUR128_FRAMELOG}"
+ if len(sources) == 1:
+ cmd += ["-af", meter]
+ else:
+ pads = "".join(f"[{i}:a]" for i in range(len(sources)))
+ cmd += ["-filter_complex", f"{pads}amix=inputs={len(sources)}:normalize=0,{meter}"]
+ cmd += ["-f", "null", "-"]
try:
- import pyloudnorm as pyln
+ if job is not None:
+ result = run_registered(job, cmd, TIMEOUT_ANALYZE)
+ else:
+ result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_ANALYZE)
+ except (subprocess.SubprocessError, OSError) as e:
+ logger.warning("loudness measurement failed: %s", e)
+ return None, None
+ if result.returncode != 0:
+ logger.warning("loudness measurement failed: ffmpeg exit %s", result.returncode)
+ return None, None
+ return parse_ebur128_summary(result.stderr.decode("utf-8", errors="replace"))
- 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 dynamic_range_db(lufs: float | None, peak_db: float | None) -> float | None:
+ """The card's Dynamic Range: true peak minus integrated loudness, the
+ peak-to-loudness ratio (PLR) in dB. It is NOT EBU R128's loudness range
+ (LRA, the spread of short-term loudness): a steady loud master and a
+ dynamic one can share an LRA, while PLR is what separates a crushed master
+ (about 6) from an open one (14 and up)."""
+ if lufs is None or peak_db is None:
+ return None
+ return round(peak_db - lufs, 1)
def _load_audio_ffmpeg(
@@ -339,6 +389,19 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
them, and the decode is registered so cancel can stop it outright (#748)."""
logger.info("analyze: entering for job %s, source=%s", job.id, source)
_set(job, status="analyzing", progress=0.0, stage="Analyzing audio...")
+ # Loudness first, and on its own: it needs no librosa, and it reads the
+ # whole stereo source, not the 22 kHz mono 180 s buffer the key and tempo use.
+ lufs, peak_db = measure_loudness([source], job=job)
+ check_cancel(job)
+ _set(
+ job,
+ lufs=lufs,
+ peak_db=peak_db,
+ dynamic_range=dynamic_range_db(lufs, peak_db),
+ loudness_version=LOUDNESS_VERSION,
+ # Measured afresh from the source, not measured again (#923).
+ loudness_remeasured=None,
+ )
try:
import librosa
except ImportError:
@@ -380,15 +443,6 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
key, scale, key_confidence = detected if detected else (None, None, None)
check_cancel(job)
- # 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
@@ -408,9 +462,6 @@ def analyze(job: Job, source: Path) -> tuple[int | None, str | None]:
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",
diff --git a/app/pipeline/audio_encoders.py b/app/pipeline/audio_encoders.py
new file mode 100644
index 00000000..f7522e14
--- /dev/null
+++ b/app/pipeline/audio_encoders.py
@@ -0,0 +1,210 @@
+"""Which audio export formats the FFmpeg this process runs can encode.
+
+One answer for the whole backend and, through /api/health, for the UI. The
+desktop shell requires pcm_s16le, flac, libmp3lame and aac in the FFmpeg it
+installs, and treats libvorbis as optional (#824): the per-architecture macOS
+download and Homebrew's plain `ffmpeg` have none. Without libvorbis everything
+works except OGG export, which the UI then shows as unavailable and the export
+routes refuse with a clear 422 instead of a failed render. The same goes for
+a transposed export, which needs the rubberband filter (`transpose_export`).
+
+`ffmpeg -encoders` and `ffmpeg -filters` each run once per binary. The results
+are cached against `ffmpeg_identity()`, the same key
+app/pipeline/video_encoder.py caches its probes under, so a replaced binary is
+probed again and nothing else is.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import subprocess
+import threading
+
+from app.core.config import TIMEOUT_AUDIO_ENCODER_LIST, TIMEOUT_AUDIO_FILTER_LIST, ffmpeg_executable
+from app.pipeline.video_encoder import ffmpeg_identity
+
+logger = logging.getLogger("stemdeck.encoders")
+
+# The encoder each audio export format is rendered with (app/api/stems.py's
+# _ENCODE_ARGS; MP3 names no encoder there, and libmp3lame is the only MP3
+# encoder FFmpeg has). OGG needs libvorbis specifically: the native "vorbis"
+# encoder most builds also list is experimental and not what the export uses.
+EXPORT_FORMAT_ENCODERS = {
+ "wav": "pcm_s16le",
+ "mp3": "libmp3lame",
+ "flac": "flac",
+ "ogg": "libvorbis",
+}
+
+# Safe to show anyone: names no path, says what to do.
+OGG_UNAVAILABLE_DETAIL = (
+ "OGG export needs an FFmpeg with Vorbis (libvorbis) support, and the FFmpeg "
+ "StemDeck is using has none. Export as WAV, MP3 or FLAC instead, or install "
+ "an FFmpeg that includes libvorbis (on a Mac: brew install ffmpeg-full)."
+)
+
+# One line of `ffmpeg -encoders`: six flag characters (type V/A/S, then five
+# capability flags or dots), the encoder name, its description. The legend
+# above the "------" line has the same shape with "=" for a name.
+_ENCODER_LINE = re.compile(r"^\s*[VAS][A-Z.]{5}\s+(\S+)")
+
+_cache: dict[str, frozenset[str] | None] = {}
+_lock = threading.Lock()
+
+
+def parse_encoder_names(listing: str) -> frozenset[str]:
+ """The encoder names in an `ffmpeg -encoders` listing."""
+ names = set()
+ for line in listing.splitlines():
+ m = _ENCODER_LINE.match(line)
+ if m and m.group(1) != "=":
+ names.add(m.group(1))
+ return frozenset(names)
+
+
+def _list_encoders() -> frozenset[str] | None:
+ try:
+ out = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ [ffmpeg_executable(), "-hide_banner", "-encoders"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_AUDIO_ENCODER_LIST,
+ )
+ except (OSError, subprocess.SubprocessError):
+ logger.warning("ffmpeg -encoders failed; export formats unknown", exc_info=True)
+ return None
+ if out.returncode != 0:
+ logger.warning("ffmpeg -encoders exited %s; export formats unknown", out.returncode)
+ return None
+ names = parse_encoder_names(out.stdout)
+ return names or None
+
+
+def ffmpeg_encoders() -> frozenset[str] | None:
+ """The encoders the active ffmpeg has, or None when that is unknown (no
+ ffmpeg to run, or the listing failed). Unknown is not "none": callers
+ treat it as they did before this probe existed."""
+ ident, _version = ffmpeg_identity()
+ if not ident:
+ return None
+ with _lock:
+ if ident in _cache:
+ return _cache[ident]
+ names = _list_encoders()
+ with _lock:
+ _cache[ident] = names
+ return names
+
+
+def export_formats() -> dict[str, bool] | None:
+ """{format: whether the active ffmpeg can encode it} for the audio export
+ formats, or None when unknown."""
+ names = ffmpeg_encoders()
+ if names is None:
+ return None
+ return {fmt: encoder in names for fmt, encoder in EXPORT_FORMAT_ENCODERS.items()}
+
+
+def unavailable_detail(fmt: str) -> str | None:
+ """Why `fmt` cannot be exported, as a safe message for a 422, or None.
+
+ Only when the active ffmpeg is known to lack its encoder: unknown reads as
+ available, so an export is tried as it always was."""
+ formats = export_formats()
+ if formats is None or formats.get(fmt) is not False:
+ return None
+ if fmt == "ogg":
+ return OGG_UNAVAILABLE_DETAIL
+ return (
+ f"{fmt.upper()} export needs the {EXPORT_FORMAT_ENCODERS[fmt]} encoder, and the "
+ "FFmpeg StemDeck is using does not have it."
+ )
+
+
+# Transposed export (#592, #858). A lane shifted in the studio is rendered with
+# ffmpeg's rubberband filter, and librubberband is GPL, so plenty of builds
+# omit it: Homebrew's plain ffmpeg, and the per-architecture macOS download the
+# desktop shell installs. Without it every export that carries a key change is
+# refused with a 422, and /api/health says so first so the export menu can.
+_RUBBERBAND = re.compile(r"\brubberband\b")
+_filter_cache: dict[str, bool] = {}
+
+
+def _has_rubberband() -> bool:
+ """Whether `ffmpeg -filters` lists rubberband. Any probe problem reads as
+ no: an export that quietly used a different algorithm would be worse than
+ one that says it cannot transpose."""
+ try:
+ out = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ [ffmpeg_executable(), "-hide_banner", "-filters"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_AUDIO_FILTER_LIST,
+ )
+ except (OSError, subprocess.SubprocessError):
+ # A build without the filter and an ffmpeg that would not run both
+ # reach the user as "cannot transpose", so say here which it was.
+ logger.warning("ffmpeg -filters failed; treating transpose as unavailable", exc_info=True)
+ return False
+ if out.returncode != 0:
+ logger.warning(
+ "ffmpeg -filters exited %s; treating transpose as unavailable", out.returncode
+ )
+ return False
+ return bool(_RUBBERBAND.search(out.stdout))
+
+
+def transpose_export() -> bool | None:
+ """Whether the active ffmpeg can render a transposed export (it has the
+ rubberband filter), or None when there is no ffmpeg to ask.
+
+ Asked once per binary, cached under `ffmpeg_identity()` like the encoder
+ list, so a replaced ffmpeg (the desktop shell installs one after the
+ backend can already be running) is asked again."""
+ ident, _version = ffmpeg_identity()
+ if not ident:
+ return None
+ with _lock:
+ if ident in _filter_cache:
+ return _filter_cache[ident]
+ found = _has_rubberband()
+ with _lock:
+ _filter_cache[ident] = found
+ return found
+
+
+# The oldest FFmpeg StemDeck runs on. Every mixdown and every master ends in
+# alimiter with latency=1 (app/pipeline/take_render.py, mastering.py), an
+# option FFmpeg 5.1 added: 4.4 and 5.0 reject the whole filter graph, so every
+# export and every master measure fails. Debian 12 ships 5.1; Ubuntu 22.04's
+# apt ffmpeg is 4.4.
+MIN_FFMPEG_VERSION = (5, 1)
+_VERSION_LINE = re.compile(r"ffmpeg version n?(\d+)\.(\d+)")
+
+
+def ffmpeg_too_old() -> str | None:
+ """The version of the active ffmpeg when it is older than
+ MIN_FFMPEG_VERSION, else None.
+
+ Read from `ffmpeg_identity()`'s version line, which is cached per binary,
+ so this runs nothing of its own. A version that names no release (a git
+ build, "N-117000-g...") is not called old, and neither is no ffmpeg."""
+ _ident, line = ffmpeg_identity()
+ m = _VERSION_LINE.match(line)
+ if m is None or (int(m[1]), int(m[2])) >= MIN_FFMPEG_VERSION:
+ return None
+ return line.split()[2]
+
+
+def reset_cache() -> None:
+ """Forget every probe (tests)."""
+ with _lock:
+ _cache.clear()
+ _filter_cache.clear()
diff --git a/app/pipeline/backing.py b/app/pipeline/backing.py
new file mode 100644
index 00000000..8826d990
--- /dev/null
+++ b/app/pipeline/backing.py
@@ -0,0 +1,343 @@
+"""Backing tracks: a song without one of its stems, mixed on demand.
+
+Streamer Mode lets a musician play one part themselves ("I play: Bass") over
+the rest of the song. Demucs always leaves all six stems on disk
+(stems/.wav, STEM_NAMES), whatever the user selected at import, so the
+backing is the sum of every stem file but the removed one: no separation runs
+again.
+
+The file is kept in the data cache, `//backing_without_
+.wav`, with a small `.json` beside it naming the stem files it was mixed
+from (size and mtime). An existing file whose sources are unchanged is never
+rebuilt; one whose stems changed (a re-split) is. One build per (job, file) at
+a time: a second caller waits for the first and then finds the file. A file is
+only ever a sum of the stems, so the cache's budget may drop it (_prune_cache):
+the next request builds it again.
+
+Voice is the vocals stem. vocals.wav holds lead and backing vocals together
+and is never deleted by the on-demand lead/backing split (#275), so removing
+it removes both; the split files are never mixed in. A job whose vocals.wav
+is missing but whose split exists falls back to the two split files for the
+"everything but X" sum, and leaves both out when Voice is removed.
+
+The mix sums the stems as make_original_track does (amix normalize=0: each
+at its own level), in float, and is written as 16-bit PCM at the stems' rate.
+Demucs stems sum back to about the original song, so leaving one out stays at
+or below the song's level; should the sum still pass full scale (a stem that
+was cancelling another), the whole file is scaled down to just under it
+rather than clipped.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import threading
+import time
+import uuid
+from pathlib import Path
+
+import numpy as np
+import soundfile as sf
+
+from app.core.config import CACHE_DIR, EXTRA_STEM_NAMES, JOB_ID_RE, STEM_NAMES
+from app.core.stem_files import stem_exists, stem_path
+
+logger = logging.getLogger("stemdeck.backing")
+
+# The stems a backing can leave out: the six Demucs stems.
+REMOVABLE = STEM_NAMES
+_PEAK_CEILING = 0.999
+_BLOCK = 1 << 16
+
+# Every job's backing and rest files, one folder per job. 1 GiB holds about
+# 24 sums of a four-minute song (42 MB each at 44.1 kHz stereo), and one sum
+# of a 60-minute song (635 MB).
+CACHE_ROOT = CACHE_DIR / "backing"
+CACHE_MAX_BYTES = 1 << 30
+# A file served this recently may still be streaming (the audio engine reads
+# it in Range windows, one request each): the budget never evicts it.
+_IN_USE_SEC = 60.0
+_BACKING_PREFIX = "backing_without_"
+
+# One per (job id, file name), dropped with the job (forget_job).
+_locks: dict[tuple[str, str], threading.Lock] = {}
+_locks_guard = threading.Lock()
+_prune_guard = threading.Lock()
+
+
+class BackingError(RuntimeError):
+ """The backing could not be made (stems missing or unreadable)."""
+
+
+def in_cache(path: Path) -> bool:
+ return path.resolve().is_relative_to(CACHE_ROOT.resolve())
+
+
+def backing_path(job_id: str, remove: str) -> Path:
+ return CACHE_ROOT / job_id / f"{_BACKING_PREFIX}{remove}.wav"
+
+
+def backing_inputs(stems_dir: Path, remove: str) -> list[Path]:
+ """The stem files the backing without `remove` is the sum of."""
+ if remove not in REMOVABLE:
+ raise ValueError(f"unknown stem {remove!r}")
+ names = [n for n in STEM_NAMES if n != remove]
+ # WAV or MP3, whichever the song keeps (#783); libsndfile reads both.
+ paths = [stem_path(stems_dir, n) for n in names]
+ present = [p for p in paths if p.is_file()]
+ vocals_gone = not stem_exists(stems_dir, "vocals")
+ if remove != "vocals" and vocals_gone:
+ # No vocals.wav but a finished split: its two halves stand in for it.
+ split = [stem_path(stems_dir, n) for n in EXTRA_STEM_NAMES]
+ if all(p.is_file() for p in split):
+ present += split
+ return present
+
+
+def _signature(paths: list[Path]) -> dict[str, list[int]]:
+ out = {}
+ for p in paths:
+ st = p.stat()
+ out[p.name] = [st.st_size, st.st_mtime_ns]
+ return out
+
+
+def _fresh(out: Path, meta: Path, paths: list[Path]) -> bool:
+ if not out.is_file() or not meta.is_file():
+ return False
+ try:
+ return json.loads(meta.read_text(encoding="utf-8")).get("sources") == _signature(paths)
+ except (OSError, ValueError):
+ return False
+
+
+def _lock(job_id: str, name: str) -> threading.Lock:
+ with _locks_guard:
+ return _locks.setdefault((job_id, name), threading.Lock())
+
+
+def forget_job(job_id: str) -> None:
+ """A deleted job's sums and locks go with it. A lock still held (a build
+ or a request in flight) stays, and a file that build finishes stays until
+ the budget evicts it."""
+ if not JOB_ID_RE.match(job_id):
+ return
+ with _locks_guard:
+ for key, lock in list(_locks.items()):
+ if key[0] == job_id and not lock.locked():
+ del _locks[key]
+ shutil.rmtree(CACHE_ROOT / job_id, ignore_errors=True)
+
+
+def _mix(paths: list[Path], out: Path) -> float:
+ """Sum `paths` into `out` (16-bit PCM). Returns the gain applied (1.0
+ unless the sum passed full scale). Two passes over the files, a block at
+ a time: the peak, then the write."""
+ infos = [sf.info(str(p)) for p in paths]
+ rate = infos[0].samplerate
+ channels = max(i.channels for i in infos)
+ if any(i.samplerate != rate for i in infos):
+ raise BackingError("stems at different sample rates")
+ frames = max(i.frames for i in infos)
+
+ def blocks():
+ handles = [sf.SoundFile(str(p)) for p in paths]
+ try:
+ done = 0
+ while done < frames:
+ n = min(_BLOCK, frames - done)
+ acc = np.zeros((n, channels), dtype=np.float64)
+ for h in handles:
+ data = h.read(n, dtype="float64", always_2d=True)
+ if data.shape[1] == 1 and channels > 1:
+ data = np.repeat(data, channels, axis=1)
+ acc[: data.shape[0]] += data
+ yield acc
+ done += n
+ finally:
+ for h in handles:
+ h.close()
+
+ peak = 0.0
+ for acc in blocks():
+ peak = max(peak, float(np.max(np.abs(acc))) if acc.size else 0.0)
+ gain = 1.0 if peak <= _PEAK_CEILING else _PEAK_CEILING / peak
+ with sf.SoundFile(
+ str(out), "w", samplerate=rate, channels=channels, subtype="PCM_16", format="WAV"
+ ) as w:
+ for acc in blocks():
+ w.write(acc * gain if gain != 1.0 else acc)
+ return gain
+
+
+def ensure_backing(job_id: str, job_dir: Path, remove: str) -> tuple[Path, bool]:
+ """The backing without `remove` for the job in `job_dir`, built when it is
+ missing or stale. Returns (path, built). Blocking; one build per (job,
+ stem) at a time. Raises ValueError for an unknown stem, BackingError when
+ there is nothing to mix."""
+ stems_dir = job_dir / "stems"
+ paths = backing_inputs(stems_dir, remove)
+ if not paths:
+ raise BackingError("no stems to mix")
+ out = backing_path(job_id, remove)
+ built = _ensure_sum(job_id, job_dir, out, paths, {"removed": remove})
+ return out, built
+
+
+def _ensure_sum(job_id: str, job_dir: Path, out: Path, paths: list[Path], meta_extra: dict) -> bool:
+ """Build `out` as the sum of `paths` unless it is fresh, then hold the
+ cache to its budget. Returns whether it was built. One build per file at a
+ time: a second caller waits for the first and then finds the file. A
+ fresh file is touched, so the budget counts it as just used."""
+ meta = out.with_suffix(".json")
+ with _lock(job_id, out.name):
+ if _fresh(out, meta, paths):
+ try:
+ os.utime(out)
+ except OSError:
+ logger.debug("backing: could not touch %s", out.name, exc_info=True)
+ return False
+ out.parent.mkdir(parents=True, exist_ok=True)
+ tmp = out.with_name(f".{out.name}.{uuid.uuid4().hex}.tmp.wav")
+ try:
+ gain = _mix(paths, tmp)
+ os.replace(tmp, out)
+ except (OSError, RuntimeError, ValueError) as exc:
+ raise BackingError("the backing could not be mixed") from exc
+ finally:
+ tmp.unlink(missing_ok=True)
+ meta.write_text(
+ json.dumps({**meta_extra, "sources": _signature(paths), "gain": gain}),
+ encoding="utf-8",
+ )
+ if gain != 1.0:
+ logger.info("[%s] %s scaled by %.4f to stay unclipped", job_id, out.name, gain)
+ _drop_from_song_folder(job_dir)
+ _prune_cache(keep=out)
+ return True
+
+
+def _drop_from_song_folder(job_dir: Path) -> None:
+ """Builds before the cache kept these sums in the song folder. Once the
+ job has a sum in the cache, those copies are only disk space."""
+ for pattern in (f"{_BACKING_PREFIX}*", f"{REST_PREFIX}*"):
+ for old in job_dir.glob(pattern):
+ try:
+ old.unlink()
+ except OSError:
+ logger.debug("backing: old %s still in use", old.name, exc_info=True)
+
+
+def _prune_cache(keep: Path) -> None:
+ """Hold every job's sums under CACHE_MAX_BYTES, least recently used first,
+ after a build. Never evicted: `keep` (just built, about to be served, even
+ on a volume whose clock disagrees with ours), a file used in the last
+ _IN_USE_SEC, and a file whose lock a build or a request holds. A file
+ Windows refuses to delete (still being read) is left for the next prune;
+ so is what stays over budget."""
+ with _prune_guard:
+ entries = []
+ for p in CACHE_ROOT.glob("*/*.wav"):
+ # Only the sums this module wrote: not a build in progress (".")
+ # and nothing else a misplaced STEMDECK_CACHE_DIR may hold.
+ if not JOB_ID_RE.match(p.parent.name) or not p.name.startswith(
+ (_BACKING_PREFIX, REST_PREFIX)
+ ):
+ continue
+ try:
+ st = p.stat()
+ except OSError:
+ continue # evicted or deleted since the scan
+ entries.append((st.st_mtime, st.st_size, p))
+ total = sum(size for _, size, _ in entries)
+ entries.sort()
+ in_use = time.time() - _IN_USE_SEC
+ for mtime, size, p in entries:
+ if total <= CACHE_MAX_BYTES:
+ break
+ if p == keep or mtime > in_use:
+ continue
+ lock = _lock(p.parent.name, p.name)
+ if not lock.acquire(blocking=False):
+ continue
+ try:
+ # Asked for since the scan: it counts as in use now.
+ if p.stat().st_mtime > in_use:
+ continue
+ p.unlink()
+ total -= size
+ p.with_suffix(".json").unlink(missing_ok=True)
+ except OSError:
+ logger.debug("backing prune: %s still in use", p.name, exc_info=True)
+ finally:
+ lock.release()
+
+
+# ── The rest of the song, for any set of stems ──
+#
+# A studio mode (Practice, Advanced, Karaoke) chooses which stems are lanes of
+# their own; its Original is the sum of every other stem. `rest_.wav` is
+# that sum for one set (base stems, sorted, joined by "-"), with the same
+# signature, lock, peak guard and cache budget as the backing. The engine plays
+# Original from the stem files themselves; this file is for what needs one file
+# (the streaming engine, the lane's download, a full-decode waveform).
+
+REST_PREFIX = "rest_"
+_SET_SEP = "-"
+
+
+def parse_rest_set(raw: str) -> tuple[str, ...]:
+ """The stems of a rest set as spelled in its URL and file name: base stems
+ joined by "-", sorted, each once, 1 to 6 of them. Anything else raises
+ ValueError, so one set has exactly one spelling and one file."""
+ names = tuple(raw.split(_SET_SEP)) if raw else ()
+ if not 1 <= len(names) <= len(STEM_NAMES):
+ raise ValueError("a rest set holds 1 to 6 stems")
+ if any(n not in STEM_NAMES for n in names):
+ raise ValueError("unknown stem in the set")
+ if len(set(names)) != len(names) or list(names) != sorted(names):
+ raise ValueError("a rest set is sorted and names each stem once")
+ return names
+
+
+def rest_name(names: tuple[str, ...] | list[str]) -> str:
+ """The canonical spelling of a set of stems."""
+ return _SET_SEP.join(sorted(set(names)))
+
+
+def rest_path(job_id: str, names: tuple[str, ...]) -> Path:
+ return CACHE_ROOT / job_id / f"{REST_PREFIX}{rest_name(names)}.wav"
+
+
+def rest_inputs(stems_dir: Path, names: tuple[str, ...]) -> list[Path]:
+ """The stem files the rest of `names` sums. Every named stem must be on
+ disk (a missing one raises BackingError: the sum would be another song),
+ except Voice, whose lead/backing halves stand in for a missing vocals.wav
+ as they do for the backing. The split halves are never added otherwise:
+ vocals.wav already holds them."""
+ paths: list[Path] = []
+ for name in names:
+ p = stem_path(stems_dir, name)
+ if p.is_file():
+ paths.append(p)
+ continue
+ split = [stem_path(stems_dir, n) for n in EXTRA_STEM_NAMES]
+ if name == "vocals" and all(s.is_file() for s in split):
+ paths += split
+ continue
+ raise BackingError(f"stem {name} is missing")
+ return paths
+
+
+def ensure_rest(job_id: str, job_dir: Path, names: tuple[str, ...]) -> tuple[Path, bool]:
+ """The sum of the stems `names` for the job in `job_dir`, built when it is
+ missing or stale. Returns (path, built). Blocking. Raises ValueError for a
+ bad set and BackingError when a stem is missing or the mix fails."""
+ names = parse_rest_set(rest_name(names))
+ paths = rest_inputs(job_dir / "stems", names)
+ out = rest_path(job_id, names)
+ built = _ensure_sum(job_id, job_dir, out, paths, {"names": list(names)})
+ return out, built
diff --git a/app/pipeline/beat_detect.py b/app/pipeline/beat_detect.py
index 38ccf766..7400c50f 100644
--- a/app/pipeline/beat_detect.py
+++ b/app/pipeline/beat_detect.py
@@ -23,7 +23,7 @@
STEMDECK_BEAT_DETECTOR=librosa.
Deliberately *not* madmom: its pretrained models are CC BY-NC-SA 4.0, which
-would quietly make an Apache-2.0 project non-commercial for everyone who
+would quietly make an AGPL-3.0 project non-commercial for everyone who
installs it. Its DBN post-processor is BSD and model-free, and beat_this can use
it, but doing so needs a git install with an undeclared Cython build step on
three platforms. The interior-gap fill in beatgrid.py recovers what the DBN was
@@ -32,15 +32,19 @@
from __future__ import annotations
+import gc
import logging
import threading
from app.core.config import BEAT_DETECTOR, BEAT_MODEL_CHECKPOINT, BEATGRID_HOP
+from app.pipeline import idle_unload
+from app.pipeline.idle_unload import IdleResource
logger = logging.getLogger("stemdeck.beatdetect")
# Loading the checkpoint costs ~1 s and 81 MB of RAM, so the model is built once
-# and reused. Guarded because pipeline stages can run from a worker thread.
+# and reused until the idle timer drops it (MODEL below). Guarded because
+# pipeline stages can run from a worker thread.
_model = None
_model_failed = False
_lock = threading.Lock()
@@ -93,15 +97,41 @@ def reset_model_cache() -> None:
_model_failed = False
+def _drop_model() -> None:
+ """Let go of the loaded model; the next detection loads it again. A cached
+ failure stays cached, for the reason _get_model gives."""
+ global _model
+ with _lock:
+ _model = None
+
+
+# Dropped by the idle timer with the separation worker (#975). Reloading costs
+# 0.3 to 0.4 s. Measured on Windows, 2026-11-05, the server's memory did not go
+# down when the model was dropped (the heap keeps the freed pages and the next
+# load reuses them, flat over ten cycles); what the drop gives back on Linux is
+# not measured.
+MODEL = idle_unload.register(
+ IdleResource(
+ "beat model",
+ lambda: _model is not None,
+ lambda: _drop_model(),
+ # Outside the idle lock, so a detection arriving meanwhile does not
+ # wait on a full collection.
+ after_unload=gc.collect,
+ )
+)
+
+
def _detect_model(y: object, sr: int) -> tuple[list[float], list[float]] | None:
- model = _get_model()
- if model is None:
- return None
- try:
- beats, downbeats = model(y, sr)
- except Exception:
- logger.warning("beat model inference failed -- falling back", exc_info=True)
- return None
+ with MODEL.in_use():
+ model = _get_model()
+ if model is None:
+ return None
+ try:
+ beats, downbeats = model(y, sr)
+ except Exception:
+ logger.warning("beat model inference failed -- falling back", exc_info=True)
+ return None
beats = [float(t) for t in beats]
downbeats = [float(t) for t in downbeats]
if len(beats) < 2:
@@ -110,7 +140,7 @@ def _detect_model(y: object, sr: int) -> tuple[list[float], list[float]] | None:
return beats, downbeats
-def _detect_librosa(y: object, sr: int, onset_env: object) -> tuple[list[float], list[float]]:
+def _detect_librosa(sr: int, onset_env: object) -> tuple[list[float], list[float]]:
"""Classic tracker. Returns (beats, []) -- it has no downbeat notion."""
import librosa
@@ -140,5 +170,5 @@ def detect(y: object, sr: int, onset_env: object) -> tuple[list[float], list[flo
return got[0], got[1], "beat_this"
if BEAT_DETECTOR == "model":
raise RuntimeError("beat model required (STEMDECK_BEAT_DETECTOR=model) but unavailable")
- beats, downbeats = _detect_librosa(y, sr, onset_env)
+ beats, downbeats = _detect_librosa(sr, onset_env)
return beats, downbeats, "librosa"
diff --git a/app/pipeline/click_render.py b/app/pipeline/click_render.py
index 08119a69..45d90faa 100644
--- a/app/pipeline/click_render.py
+++ b/app/pipeline/click_render.py
@@ -1,28 +1,33 @@
"""Render the click track to audio so it can be included in exports.
-Playback synthesises the click in the browser with Web Audio oscillators, which
-never reach the server -- the export path is ffmpeg summing stem WAVs. This
-module produces an equivalent WAV so the click can be mixed in as one more
-ffmpeg input.
-
-The voice is reproduced from `static/js/metronome.js` deliberately literally,
-including its exponential gain ramps, because an export that sounds different
-from what the user monitored is worse than no export option at all. The two
-implementations are pinned together by `tests/test_click_render.py`, which
-asserts the rendered peaks land on the same beats the scheduler would use.
+Playback synthesises the click in the browser with Web Audio, which never
+reaches the server -- the export path is ffmpeg summing stem WAVs. This module
+produces an equivalent WAV so the click can be mixed in as one more ffmpeg
+input, or shipped as its own stem in the stems zip (#805).
+
+The voices come from the click-sound presets (`click_sounds.py`, defined once
+in static/js/clickSounds.js), synthesised by the same formulas the browser
+uses, because an export that sounds different from what the user monitored is
+worse than no export option at all. The timing is pinned by
+`tests/test_click_render.py`, which asserts the rendered peaks land on the same
+beats the scheduler would use; the sound by tests/test_click_sounds.py.
"""
from __future__ import annotations
import hashlib
import logging
+import uuid
import wave
from pathlib import Path
+from app.pipeline.click_sounds import DEFAULT_CLICK_SOUND, render_voice
+
logger = logging.getLogger("stemdeck.clickrender")
-# Mirrors the constants at the top of static/js/metronome.js. Changing either
-# side without the other makes exports diverge from playback.
+# The Classic preset's voice, as it was before presets existed (#805). The
+# sound itself is now the "classic" entry of static/js/clickSounds.js;
+# tests/test_click_sounds.py asserts that entry still says exactly this.
CLICK_FREQ = 1000.0
# Group starts sit between the downbeat and a plain beat in both pitch and
# level, so a grouped bar reads as "strong, medium, weak" rather than as three
@@ -51,6 +56,13 @@
_VALID_MULTIPLIERS = (0.5, 1.0, 2.0)
+# The rendered click is mono; every export takes it to stereo with this filter,
+# the mono click on both channels at unity (#833). That is what the app plays:
+# Web Audio up-mixes the mono click into the stereo master as L = R = M. Left
+# to amix, the mix graph would insert libswresample's default mono-to-stereo
+# matrix instead, which puts the click 3 dB lower on each channel.
+CLICK_TO_STEREO = "pan=stereo|c0=c0|c1=c0"
+
def default_grouping(beats_per_bar: int) -> list[int]:
"""How a bar of `beats_per_bar` divides, when the user has not said.
@@ -309,22 +321,6 @@ def count_in_beats(
return lead_in, clicks
-def _voice(peak: float, freq: float, sample_rate: int):
- """One click as a float array: a sine under the scheduler's two exponential
- gain ramps (RAMP_FLOOR -> peak over the attack, then back down over the rest
- of the decay). Phase starts at zero, exactly as a fresh OscillatorNode."""
- import numpy as np
-
- n = int(round(CLICK_DECAY * sample_rate))
- t = np.arange(n) / sample_rate
- attack = t <= CLICK_ATTACK
- env = np.empty(n)
- env[attack] = RAMP_FLOOR * (peak / RAMP_FLOOR) ** (t[attack] / CLICK_ATTACK)
- span = CLICK_DECAY - CLICK_ATTACK
- env[~attack] = peak * (RAMP_FLOOR / peak) ** ((t[~attack] - CLICK_ATTACK) / span)
- return np.sin(2.0 * np.pi * freq * t) * env
-
-
def cache_key(
job_id: str,
beats: list[float],
@@ -338,6 +334,7 @@ def cache_key(
start: float | None = None,
end: float | None = None,
groups: list[int] | None = None,
+ sound: str = DEFAULT_CLICK_SOUND,
) -> str:
"""Every input to the render is in the key. Beats are included by digest
rather than by job id alone: an edited grid must not hit a cache entry
@@ -362,6 +359,10 @@ def cache_key(
if count_in_bars > 0:
seg = f"{'' if start is None else f'{start:.3f}'}:{'' if end is None else f'{end:.3f}'}"
raw += f"|ci{count_in_bars}|clk{int(include_click)}|{seg}"
+ # Only a non-default sound enters the key (#805), for the same reason as
+ # the grouping: every Classic export keeps the key it always had.
+ if sound != DEFAULT_CLICK_SOUND:
+ raw += f"|s{sound}"
return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()
@@ -383,12 +384,18 @@ def _song_click_events(
def _render_events(
- dest: Path, events: list[tuple[float, int]], duration: float, sample_rate: int
+ dest: Path,
+ events: list[tuple[float, int]],
+ duration: float,
+ sample_rate: int,
+ sound: str = DEFAULT_CLICK_SOUND,
+ frames: int | None = None,
) -> Path | None:
"""Stamp a list of (time, level) clicks into a mono WAV of `duration`
- seconds. The single place clicks become audio, so playback parity only has
- to be maintained against the three voices, not against two render paths."""
- total = int(round(duration * sample_rate))
+ seconds, or of exactly `frames` samples when given. The single place clicks
+ become audio, so playback parity only has to be maintained against the
+ preset voices (click_sounds.py), not against several render paths."""
+ total = int(frames) if frames is not None else int(round(duration * sample_rate))
if not events or total <= 0:
return None
@@ -401,9 +408,8 @@ def _render_events(
buf = np.zeros(total, dtype=np.float32)
# Only three distinct voices, so render each once and stamp it in.
voices = {
- LEVEL_WEAK: _voice(CLICK_PEAK, CLICK_FREQ, sample_rate),
- LEVEL_GROUP: _voice(GROUP_PEAK, GROUP_FREQ, sample_rate),
- LEVEL_DOWNBEAT: _voice(ACCENT_PEAK, ACCENT_FREQ, sample_rate),
+ level: render_voice(sound, level, sample_rate)
+ for level in (LEVEL_WEAK, LEVEL_GROUP, LEVEL_DOWNBEAT)
}
for t, level in events:
@@ -420,13 +426,18 @@ def _render_events(
pcm = (buf * 32767.0).astype(" Path | None:
"""Write a mono WAV of the click track spanning the whole track.
+ `frames` fixes the length in samples instead of `duration`: the stems zip
+ passes the decoded stems' own length so the click stem matches them sample
+ for sample (#805).
+
Full length regardless of where the beats start, so the export's region trim
(`-ss` before every ffmpeg input) lines the click up with the stems without
any special-casing. Returns the path, or None when there is nothing to
@@ -450,7 +467,7 @@ def render_click_wav(
if multiplier not in _VALID_MULTIPLIERS:
multiplier = 1.0
events = _song_click_events(beats, bars, multiplier, accent_mode, groups)
- out = _render_events(dest, events, duration, sample_rate)
+ out = _render_events(dest, events, duration, sample_rate, sound, frames)
if out is not None:
logger.info("click render: %d beats, %.1f s -> %s", len(events), duration, dest.name)
return out
@@ -469,6 +486,7 @@ def render_count_in_wav(
start: float = 0.0,
end: float | None = None,
groups: list[int] | None = None,
+ sound: str = DEFAULT_CLICK_SOUND,
) -> tuple[Path, float] | None:
"""Render the click WAV for a count-in export, in *output* coordinates.
@@ -494,7 +512,7 @@ def render_count_in_wav(
if seg_start <= t < seg_end:
events.append((t - seg_start + lead_in, level))
- out = _render_events(dest, events, lead_in + seg_len, sample_rate)
+ out = _render_events(dest, events, lead_in + seg_len, sample_rate, sound)
if out is None:
return None
logger.info("count-in render: %.3f s lead-in, %d clicks -> %s", lead_in, len(events), dest.name)
diff --git a/app/pipeline/click_sounds.py b/app/pipeline/click_sounds.py
new file mode 100644
index 00000000..a89d1a1e
--- /dev/null
+++ b/app/pipeline/click_sounds.py
@@ -0,0 +1,198 @@
+"""Click-sound presets (#805), the Python half.
+
+The presets are defined once, in static/js/clickSounds.js, because the live
+click is synthesised in the browser and an export is rendered here: a preset
+that sounded different in the exported file than it did while monitoring would
+defeat the point. This module parses the strict-JSON block between that file's
+markers and runs the same synthesis as its `renderVoice()`, formula for
+formula. tests/fixtures/click_sounds.json pins both sides to one set of
+measurements (tests/test_click_sounds.py here, tests/js/click-sounds.test.mjs
+there), the same discipline that pins the click levels (#595).
+
+`DEFAULT_CLICK_SOUND` is the click StemDeck always had, and its render is
+byte-identical to the renderer before presets existed (pinned by a fingerprint
+in tests/test_click_sounds.py).
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import re
+from functools import lru_cache
+from pathlib import Path
+
+import numpy as np
+
+from app.core.config import CLICK_SOUNDS_TABLE
+
+_JSON_BLOCK = re.compile(
+ r"/\*\s*CLICK_SOUNDS_JSON_BEGIN\s*\*/(.*?)/\*\s*CLICK_SOUNDS_JSON_END\s*\*/", re.DOTALL
+)
+
+# Click strength at one beat, as click_render.py numbers it.
+_LEVEL_VOICE = ("beat", "group", "accent")
+_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
+
+
+class ClickSoundTableError(RuntimeError):
+ """static/js/clickSounds.js is missing, or its table is malformed."""
+
+
+def _check_component(pid: str, c: dict) -> None:
+ kind = c.get("type")
+ if kind not in ("sine", "noise"):
+ raise ClickSoundTableError(f"{pid}: unknown component type {kind!r}")
+ for key in ("attack", "decay", "peak"):
+ if not isinstance(c.get(key), (int, float)):
+ raise ClickSoundTableError(f"{pid}: component needs a number for {key}")
+ if not (0 < c["attack"] < c["decay"] <= 1.0) or not (0 < c["peak"] <= 1.0):
+ raise ClickSoundTableError(f"{pid}: envelope out of range")
+ if kind == "sine" and not (20 <= float(c.get("freq", 0)) <= 20000):
+ raise ClickSoundTableError(f"{pid}: sine needs a freq in 20..20000")
+ if kind == "noise":
+ if not (20 <= float(c.get("center", 0)) <= 20000) or not float(c.get("q", 0)) > 0:
+ raise ClickSoundTableError(f"{pid}: noise needs a center and a q")
+ if not isinstance(c.get("seed"), int) or not 0 < c["seed"] < 2**32:
+ raise ClickSoundTableError(f"{pid}: noise needs a 32-bit seed")
+
+
+def parse_table(text: str) -> dict:
+ """The preset table from the source of clickSounds.js, validated.
+
+ Strict: a malformed table raises rather than falling back, because the
+ export whitelist is derived from it and a silently empty one would refuse
+ every sound, including the default."""
+ m = _JSON_BLOCK.search(text)
+ if m is None:
+ raise ClickSoundTableError("clickSounds.js has no CLICK_SOUNDS_JSON block")
+ try:
+ table = json.loads(m.group(1))
+ except json.JSONDecodeError as e:
+ raise ClickSoundTableError(f"clickSounds.js table is not strict JSON: {e}") from e
+ presets = table.get("presets")
+ if not isinstance(presets, list) or not presets:
+ raise ClickSoundTableError("clickSounds.js has no presets")
+ seen: set[str] = set()
+ for p in presets:
+ pid = p.get("id")
+ if not isinstance(pid, str) or not _ID_RE.match(pid) or pid in seen:
+ raise ClickSoundTableError(f"bad or duplicate preset id {pid!r}")
+ seen.add(pid)
+ if p.get("kind") != "synth":
+ raise ClickSoundTableError(f"{pid}: only synth presets are supported")
+ if not isinstance(p.get("gain"), (int, float)) or not 0 < p["gain"] <= 4:
+ raise ClickSoundTableError(f"{pid}: gain out of range")
+ voices = p.get("voices") or {}
+ for level in _LEVEL_VOICE:
+ comps = voices.get(level)
+ if not isinstance(comps, list) or not comps:
+ raise ClickSoundTableError(f"{pid}: no {level} voice")
+ for c in comps:
+ _check_component(pid, c)
+ if table.get("default") not in seen:
+ raise ClickSoundTableError("the default click sound is not a preset")
+ if not isinstance(table.get("floor"), (int, float)) or not 0 < table["floor"] < 0.01:
+ raise ClickSoundTableError("floor out of range")
+ return table
+
+
+def load_table(path: Path = CLICK_SOUNDS_TABLE) -> dict:
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as e:
+ raise ClickSoundTableError(f"cannot read {path.name}") from e
+ return parse_table(text)
+
+
+TABLE = load_table()
+DEFAULT_CLICK_SOUND: str = TABLE["default"]
+# The export routes' whitelist: a sound id is only ever looked up here, never
+# used to build a path or a command.
+CLICK_SOUND_IDS: tuple[str, ...] = tuple(p["id"] for p in TABLE["presets"])
+_BY_ID = {p["id"]: p for p in TABLE["presets"]}
+FLOOR: float = float(TABLE["floor"])
+
+
+def _samples(seconds: float, sample_rate: int) -> int:
+ """Half rounds up, as in clickSounds.js (round() would round half to even)."""
+ return int(math.floor(seconds * sample_rate + 0.5))
+
+
+def _envelope(t: np.ndarray, attack: float, decay: float, peak: float) -> np.ndarray:
+ """The original click's two exponential ramps, floor -> peak -> floor."""
+ rising = t <= attack
+ env = np.empty(len(t))
+ env[rising] = FLOOR * (peak / FLOOR) ** (t[rising] / attack)
+ span = decay - attack
+ env[~rising] = peak * (FLOOR / peak) ** ((t[~rising] - attack) / span)
+ return env
+
+
+def _phase(c: dict, t: np.ndarray) -> np.ndarray:
+ f = float(c["freq"])
+ f_end = c.get("freqEnd")
+ sweep = float(c.get("sweep") or 0.0)
+ if f_end and sweep > 0 and f_end != f:
+ f_end = float(f_end)
+ k = math.log(f_end / f) / sweep
+ gliding = 2 * math.pi * f * (np.exp(k * t) - 1) / k
+ after = 2 * math.pi * f * (f_end / f - 1) / k + 2 * math.pi * f_end * (t - sweep)
+ return np.where(t < sweep, gliding, after)
+ return 2.0 * np.pi * f * t
+
+
+def _noise(c: dict, m: int, sample_rate: int) -> np.ndarray:
+ """xorshift32 white noise in [-1, 1), band-passed (RBJ, 0 dB peak).
+
+ A plain loop on purpose: it has to be the same recurrence as the JS one,
+ and a voice is a few thousand samples, rendered once per export."""
+ x = int(c["seed"]) & 0xFFFFFFFF or 1
+ w0 = 2 * math.pi * float(c["center"]) / sample_rate
+ alpha = math.sin(w0) / (2 * float(c["q"]))
+ a0 = 1 + alpha
+ b0 = alpha / a0
+ b2 = -alpha / a0
+ a1 = (-2 * math.cos(w0)) / a0
+ a2 = (1 - alpha) / a0
+ x1 = x2 = y1 = y2 = 0.0
+ out = np.empty(m)
+ for i in range(m):
+ x ^= (x << 13) & 0xFFFFFFFF
+ x ^= x >> 17
+ x ^= (x << 5) & 0xFFFFFFFF
+ u = (x / 4294967296) * 2 - 1
+ y = b0 * u + b2 * x2 - a1 * y1 - a2 * y2
+ x2, x1 = x1, u
+ y2, y1 = y1, y
+ out[i] = y
+ return out
+
+
+@lru_cache(maxsize=64)
+def _voice_cached(sound: str, level: int, sample_rate: int) -> np.ndarray:
+ preset = _BY_ID[sound]
+ comps = preset["voices"][_LEVEL_VOICE[level] if 0 <= level < 3 else "beat"]
+ n = max(_samples(float(c["decay"]), sample_rate) for c in comps)
+ out = np.zeros(n)
+ for c in comps:
+ m = _samples(float(c["decay"]), sample_rate)
+ t = np.arange(m) / sample_rate
+ src = _noise(c, m, sample_rate) if c["type"] == "noise" else np.sin(_phase(c, t))
+ out[:m] += src * _envelope(t, float(c["attack"]), float(c["decay"]), float(c["peak"]))
+ gain = float(preset["gain"])
+ if gain != 1.0:
+ out *= gain
+ out.setflags(write=False)
+ return out
+
+
+def render_voice(sound: str, level: int, sample_rate: int) -> np.ndarray:
+ """One click voice (float64, read-only) for a preset and a click level.
+
+ An unknown `sound` raises KeyError: callers validate against
+ CLICK_SOUND_IDS first, and silently clicking the wrong sound would hide a
+ bug."""
+ if sound not in _BY_ID:
+ raise KeyError(sound)
+ return _voice_cached(sound, int(level), int(sample_rate))
diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py
index f995ac3c..8abee36f 100644
--- a/app/pipeline/collect.py
+++ b/app/pipeline/collect.py
@@ -2,25 +2,37 @@
import json
import logging
+import os
+import re
import shutil
import subprocess
+import threading
import time
+import uuid
+from collections.abc import Iterable
from pathlib import Path
from app.core.config import (
+ CACHE_TEMP_MIN_AGE_SECONDS,
DEMUCS_MODEL,
FAILED_TTL_SECONDS,
JOB_TTL_SECONDS,
STEM_NAMES,
+ TAKES_DIRNAME,
TIMEOUT_FFMPEG,
+ WAV_PLAIN_HEADER_ARGS,
ffmpeg_executable,
)
from app.core.models import FINISHED_STATUSES, Job
from app.core.registry import all_jobs as registry_all
+from app.core.registry import deleted_ids as registry_deleted_ids
+from app.core.registry import holds_job_files, is_real_entry, job_dirs, quarantine_dirs, set_proc
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
+from app.core.stem_files import is_stem_audio, stem_names_on_disk, stem_path
from app.pipeline.audio_stats import scan_stem
+from app.pipeline.backing import forget_job as forget_backing
+from app.pipeline.lyrics_retime import current_run as current_retime
logger = logging.getLogger("stemdeck.collect")
@@ -68,6 +80,24 @@ def _run_ffmpeg(job: Job, cmd: list[str]) -> bool:
set_proc(job.id, None)
+def _write_wav(job: Job, cmd: list[str], out: Path) -> Path | None:
+ """Run `cmd` with a WAV output at the dot temp name `...tmp`
+ beside `out`, renamed to `out` only once ffmpeg succeeded. A kill mid-write
+ then leaves a temp file, never an `out` that recovery takes for a whole
+ stem."""
+ tmp = out.with_name(f".{out.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ if not _run_ffmpeg(job, [*cmd, "-f", "wav", str(tmp)]):
+ return None
+ os.replace(tmp, out)
+ return out
+ finally:
+ try:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ logger.warning("could not remove %s", tmp, exc_info=True)
+
+
def remove_job_dir(job_dir: Path) -> bool:
"""Remove a job's directory. False means files are still on disk.
@@ -93,7 +123,7 @@ def remove_job_dir(job_dir: Path) -> bool:
_TERMINAL = FINISHED_STATUSES
-def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]:
+def collect(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, for the jobs whose source
@@ -155,7 +185,7 @@ def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None
# 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)]
+ cmd += ["-c:a", "pcm_s16le", *WAV_PLAIN_HEADER_ARGS]
else:
filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs)))
cmd += [
@@ -163,9 +193,9 @@ def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None
f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0",
"-c:a",
"pcm_s16le",
- str(out),
+ *WAV_PLAIN_HEADER_ARGS,
]
- return out if _run_ffmpeg(job, cmd) else None
+ return _write_wav(job, cmd, out)
def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | None:
@@ -204,9 +234,9 @@ def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | Non
f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0",
"-c:a",
"pcm_s16le",
- str(out),
+ *WAV_PLAIN_HEADER_ARGS,
]
- return out if _run_ffmpeg(job, cmd) else None
+ return _write_wav(job, cmd, out)
# Enough points to back the deepest zoom, not the 1x bar count: a full-width
@@ -249,7 +279,18 @@ def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> dict[str, floa
return rms_values
-def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]:
+# Serialises the read-modify-write of peaks.json between a take being saved
+# (merge_stem_peaks) and the first request of a job that had none
+# (fill_missing_peaks), which run on different threads.
+_PEAKS_WRITE_LOCK = threading.Lock()
+
+
+def merge_stem_peaks(
+ stems_dir: Path,
+ new_names: list[str],
+ sources: dict[str, Path] | None = None,
+ remove: list[str] | None = None,
+) -> dict[str, float]:
"""Add peaks/RMS for newly-produced stems (e.g. the on-demand lead/backing
vocal split, #275) into the existing peaks.json instead of recomputing
every stem. Best-effort, same as compute_stem_peaks: a failure here only
@@ -257,38 +298,126 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]:
Returns each scanned stem's RMS, from the same pass, so the caller can
extend stem presence without decoding anything twice (see
- presence_for_split)."""
+ presence_for_split).
+
+ `sources` maps a name to a WAV outside stems_dir: a recorded take lives in
+ takes//audio.wav but its waveform is served from the same
+ peaks.json as the stems, so the chunked engine needs no fallback. `remove`
+ drops entries, for a deleted take."""
path = stems_dir / "peaks.json"
- peaks: dict[str, list[list[float]]] = {}
rms_values: dict[str, float] = {}
- if path.is_file():
- try:
- peaks = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- logger.warning("could not read existing peaks.json in %s", stems_dir, exc_info=True)
-
- for name in new_names:
- wav = stems_dir / f"{name}.wav"
+ scanned: dict[str, list[list[float]]] = {}
+ # A job that never had peaks.json (separated before it existed, or made
+ # by hand) gets one here for the first time. Written with the take alone,
+ # the studio would see peaks, stream the song, and draw no stem at all.
+ # Those extra stems are drawn only: their RMS is not returned.
+ backfill = (
+ []
+ if path.is_file() or remove
+ else [n for n in _stem_wav_names(stems_dir) if n not in new_names]
+ )
+
+ for name in [*backfill, *new_names]:
+ wav = (sources or {}).get(name) or stem_path(stems_dir, name)
if not wav.is_file():
continue
try:
result, rms = scan_stem(wav, _PEAK_POINTS)
- rms_values[name] = rms
+ if name in new_names:
+ rms_values[name] = rms
if result:
- peaks[name] = result
+ scanned[name] = result
except Exception:
logger.warning("could not compute peaks for %s/%s", stems_dir.name, name, exc_info=True)
- try:
- tmp = stems_dir / "peaks.json.tmp"
- tmp.write_text(json.dumps(peaks), encoding="utf-8")
- tmp.replace(path)
- except Exception:
- logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True)
+ # Read, change and write under one lock: a take saved while the first
+ # request of an old job builds its peaks must not lose either change.
+ with _PEAKS_WRITE_LOCK:
+ peaks: dict[str, list[list[float]]] = {}
+ if path.is_file():
+ try:
+ peaks = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read existing peaks.json in %s", stems_dir, exc_info=True)
+ for name in remove or []:
+ peaks.pop(name, None)
+ peaks.update(scanned)
+ if not peaks and not path.is_file():
+ # Nothing to draw and no file yet: leave it to fill_missing_peaks.
+ return rms_values
+ try:
+ tmp = stems_dir / "peaks.json.tmp"
+ tmp.write_text(json.dumps(peaks), encoding="utf-8")
+ tmp.replace(path)
+ except Exception:
+ logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True)
return rms_values
+def _stem_wav_names(stems_dir: Path) -> list[str]:
+ # WAV or MP3 (#783): an MP3-stored song has no WAVs left.
+ return stem_names_on_disk(stems_dir)
+
+
+def fill_missing_peaks(stems_dir: Path, sources: dict[str, Path] | None = None) -> bool:
+ """Write peaks.json for a job that has none: every WAV in stems_dir, plus
+ `sources` (name -> WAV elsewhere: the recorded takes). Returns True when
+ peaks.json exists afterwards.
+
+ Jobs separated before peaks.json existed, or put together by hand, have
+ no peaks. The studio then had to decode every stem in the browser for its
+ waveforms, which meant swapping the streaming engine for the full-decode
+ one a moment after the song opened, and a take started in that moment was
+ recording into the engine being thrown away. Built once, on the first
+ request: one streamed pass per file, about 1 s for a three minute song of
+ seven stems. Entries that appear meanwhile (a take saved during the scan)
+ are kept, not overwritten.
+ """
+ path = stems_dir / "peaks.json"
+ if path.is_file():
+ return True
+ wanted: dict[str, Path] = {
+ name: stem_path(stems_dir, name) for name in _stem_wav_names(stems_dir)
+ }
+ for name, wav in (sources or {}).items():
+ if wav.is_file():
+ wanted[name] = wav
+ computed: dict[str, list[list[float]]] = {}
+ for name, wav in wanted.items():
+ try:
+ result, _rms = scan_stem(wav, _PEAK_POINTS)
+ except Exception:
+ logger.warning("could not compute peaks for %s/%s", stems_dir.name, name, exc_info=True)
+ continue
+ if result:
+ computed[name] = result
+ if not computed:
+ return False
+ with _PEAKS_WRITE_LOCK:
+ peaks: dict[str, list[list[float]]] = {}
+ if path.is_file():
+ try:
+ peaks = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read peaks.json in %s", stems_dir, exc_info=True)
+ for name, result in computed.items():
+ peaks.setdefault(name, result)
+ tmp = stems_dir / f"peaks.json.{uuid.uuid4().hex}.tmp"
+ try:
+ tmp.write_text(json.dumps(peaks), encoding="utf-8")
+ os.replace(tmp, path)
+ except OSError:
+ logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True)
+ return False
+ finally:
+ tmp.unlink(missing_ok=True)
+ logger.info(
+ "built missing peaks.json for %s (%d entries)", stems_dir.parent.name, len(computed)
+ )
+ return True
+
+
def presence_for_split(
rms_values: dict[str, float],
stem_presence: dict[str, int] | None,
@@ -324,38 +453,130 @@ def presence_for_split(
}
+def _has_takes_on_disk(job_dir: Path) -> bool:
+ """True when the job folder holds a recording, finished or not. A take in
+ progress is on disk before it is in job.takes, so the record alone is not
+ enough to know."""
+ takes = job_dir / TAKES_DIRNAME
+ try:
+ return takes.is_dir() and any(takes.iterdir())
+ except OSError:
+ return True # cannot tell, so assume there is something to keep
+
+
+def _has_stems_on_disk(job_dir: Path) -> bool:
+ stems = job_dir / "stems"
+ try:
+ # An MP3-stored song (#783) holds stems too: missing it here would let
+ # the sweep delete an unregistered folder that still has a song in it.
+ return stems.is_dir() and any(is_stem_audio(p) for p in stems.iterdir())
+ except OSError:
+ return True
+
+
+def _has_source_on_disk(job_dir: Path) -> bool:
+ """True when the job folder holds a source file of any kind: for an upload
+ it is the only copy StemDeck has (cleanup_source)."""
+ try:
+ return any(p.is_file() for p in job_dir.glob("source.*"))
+ except OSError:
+ return True
+
+
+def _keep_reason(job: Job | None, job_dir: Path, cutoff: float, deleted: set[str]) -> str | None:
+ """Why the TTL sweep must leave `job_dir` alone, or None to remove it.
+
+ The sweep is the user's own "delete finished jobs after N days" setting,
+ and it only ever means plain finished separations. It used to judge by age
+ alone, so it also deleted the user's favourites, their recorded takes, and
+ any folder at all in the library directory that was old enough, job or not.
+ Every rule below keeps something the user would not count as "a finished
+ job I no longer need"; when in doubt, keep. Only job folders reach here
+ (registry.job_dirs)."""
+ if job is not None:
+ if job.status not in _TERMINAL:
+ return "job is still active"
+ # A finished song stays "done" while this work writes into or reads
+ # from its folder. The first is the check delete, move and reset use.
+ if job.post_hoc_running():
+ return "a stem fill or vocal split is running"
+ retime = current_retime(job.id)
+ if retime is not None and retime.state == "running":
+ return "its lyrics are being timed"
+ if job.status == "stopped":
+ # Never separated, and an upload's kept source is its only copy:
+ # only the user deletes a stopped song.
+ return "stopped song"
+ if job.created_at >= cutoff:
+ return "job is newer than the cutoff"
+ if job.favorite:
+ return "favourite"
+ if job.versions:
+ # Recorded only once the user makes, renames, deletes or picks one.
+ return "has versions"
+ # This also keeps a take while its video renders: a render needs one.
+ if job.takes or job.tracks or _has_takes_on_disk(job_dir):
+ return "holds recorded takes"
+ # A job in the Trash follows the same rule as any other finished job:
+ # the Trash has no purge of its own, and this sweep never had a
+ # separate rule for it.
+ return None
+ # Unregistered. restore() adopts every job folder that has stems or a kept
+ # upload, so one that is still unregistered is either a delete whose files
+ # outlived it (tombstoned, the user asked for it gone) or one that left only
+ # StemDeck's bookkeeping behind (metadata.json, an empty stems/). Anything
+ # else is kept, a source file included: one restore() could not adopt may
+ # still be the only copy.
+ try:
+ if job_dir.stat().st_mtime >= cutoff:
+ return "unregistered folder is newer than the cutoff"
+ except OSError:
+ return "unregistered folder cannot be read"
+ if job_dir.name in deleted:
+ return None
+ if _has_takes_on_disk(job_dir):
+ return "unregistered folder holds recorded takes"
+ if _has_stems_on_disk(job_dir):
+ return "unregistered folder holds stems"
+ if _has_source_on_disk(job_dir):
+ return "unregistered folder holds a source file"
+ if not holds_job_files(job_dir):
+ return "unregistered folder holds nothing StemDeck writes"
+ return None
+
+
def sweep_old_jobs(jobs_dir: Path, ttl_seconds: int | None = None) -> None:
- """Delete job directories older than `ttl_seconds` (JOB_TTL_SECONDS when
- not given) and remove them from the in-memory registry. Called hourly from
- the background sweep loop started at app startup, and only when the user
- has asked for automatic deletion -- deciding *whether* to sweep is the
- caller's job, this one only decides what is old.
-
- 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."""
+ """Delete finished jobs older than `ttl_seconds` (JOB_TTL_SECONDS when not
+ given) and remove them from the in-memory registry. Called hourly from the
+ background sweep loop started at app startup, and only when the user has
+ asked for automatic deletion -- deciding *whether* to sweep is the caller's
+ job, this one only decides what is old and what may go.
+
+ Judged by Job.created_at, not directory mtime (which unrelated filesystem
+ events touch). Never removes an active job, a song with a fill, split or
+ lyrics timing running, a stopped song, a favourite, a song with versions, a
+ job with recorded takes or tracks, a folder whose name is not a job id, or a
+ link; an unregistered job folder goes only when it is old, abandoned,
+ holds something StemDeck writes and holds no source file. See
+ _keep_reason for each rule. Every removal is logged with its reason."""
cutoff = time.time() - (JOB_TTL_SECONDS if ttl_seconds is None else ttl_seconds)
- if not jobs_dir.is_dir():
- return
jobs = registry_all()
+ deleted = registry_deleted_ids()
removed = False
- for d in jobs_dir.iterdir():
- if not d.is_dir():
- continue
- if d.name == "failed":
- continue # the failure quarantine has its own TTL (sweep_failed_jobs)
+ # Not failed/: the failure quarantine has its own TTL (sweep_failed_jobs).
+ for d in job_dirs(jobs_dir):
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:
+ reason = _keep_reason(job, d, cutoff, deleted)
+ if reason is not None:
+ logger.debug("auto-delete kept %s: %s", d.name, reason)
continue
+ if job is not None:
+ logger.info("auto-delete removed job %s: finished and older than the cutoff", d.name)
+ else:
+ logger.info("auto-delete removed unregistered job folder %s: old and abandoned", d.name)
_rmtree(d)
registry_remove(d.name)
+ forget_backing(d.name)
removed = True
if removed:
registry_persist(jobs_dir)
@@ -366,13 +587,80 @@ def sweep_failed_jobs(jobs_dir: Path) -> None:
FAILED_TTL_SECONDS. Runs unconditionally from the hourly sweep loop --
unlike the job TTL sweep, which persistent-library deployments disable,
failure evidence must never accumulate forever (#277)."""
- failed_root = jobs_dir / "failed"
- if not failed_root.is_dir():
- return
cutoff = time.time() - FAILED_TTL_SECONDS
- for d in failed_root.iterdir():
+ for d in quarantine_dirs(jobs_dir):
try:
- if d.is_dir() and d.stat().st_mtime < cutoff:
+ if d.stat().st_mtime < cutoff:
+ logger.info("failed-job sweep removing %s: older than its TTL", d.name)
_rmtree(d)
except OSError:
logger.warning("failed-quarantine sweep could not stat %s", d, exc_info=True)
+
+
+# What a writer leaves when the process dies before its own cleanup runs: a
+# stem fill's work folder (stem_fill), a decode for the vocal split
+# (stem_files.decoded_wav), and a stem file or work folder written under a
+# dot temp name beside its final one (stem_storage, stems._ensure_cached_mp3).
+# Nothing a song keeps has one of these names.
+_JOB_TEMP_DIR_RE = re.compile(r"\.fill-[0-9a-f]{8}\Z")
+_STEMS_TEMP_DIR_RE = re.compile(r"\.decode-\w{8}\Z|\..+\.tmp\Z")
+_STEMS_TEMP_FILE_RE = re.compile(r"\..+\.tmp(\.mp3)?\Z")
+# The same in the export caches: a render or a click written beside its cache
+# entry (stems._render_to_file, stems._stream_ffmpeg, click_render), a stems
+# zip and the folder its stems are transcoded in (stems.get_all_stems_zip).
+_CACHE_TEMP_DIR_RE = re.compile(r"\.zipwork-\w{8}\Z")
+_CACHE_TEMP_FILE_RE = re.compile(r"\..+\.[0-9a-f]{32}\.tmp(\.\w+)?\Z|\.stemdeck_zip_\w{8}\.zip\Z")
+
+
+def _matching(folder: Path, dir_re: re.Pattern[str], file_re: re.Pattern[str]) -> list[Path]:
+ if not folder.is_dir() or not is_real_entry(folder):
+ return []
+ return [
+ p
+ for p in folder.iterdir()
+ if (dir_re.match(p.name) and p.is_dir()) or (file_re.match(p.name) and p.is_file())
+ ]
+
+
+def sweep_crash_leftovers(jobs_dir: Path, cache_dirs: Iterable[Path]) -> int:
+ """Remove what a killed process left half-written in every song folder and
+ in the export caches (`cache_dirs`). Returns how many went.
+
+ Call this at startup only, before the queue runs anything: no writer is
+ alive then, so every temp found is dead. Later it could take a file a
+ running job is writing. Only StemDeck's song folders are looked in, and no
+ link is followed or removed (registry.job_dirs, holds_job_files,
+ is_real_entry). A cache temp written in the last
+ CACHE_TEMP_MIN_AGE_SECONDS is left alone: it may be another server's."""
+ found: list[Path] = []
+ for job_dir in job_dirs(jobs_dir):
+ if not holds_job_files(job_dir):
+ continue
+ try:
+ found += [p for p in job_dir.iterdir() if _JOB_TEMP_DIR_RE.match(p.name) and p.is_dir()]
+ found += _matching(job_dir / "stems", _STEMS_TEMP_DIR_RE, _STEMS_TEMP_FILE_RE)
+ except OSError:
+ logger.warning("crash leftover sweep could not read %s", job_dir.name)
+ cutoff = time.time() - CACHE_TEMP_MIN_AGE_SECONDS
+ for cache_dir in cache_dirs:
+ try:
+ found += [
+ p
+ for p in _matching(cache_dir, _CACHE_TEMP_DIR_RE, _CACHE_TEMP_FILE_RE)
+ if p.stat().st_mtime < cutoff
+ ]
+ except OSError:
+ logger.warning("crash leftover sweep could not read %s", cache_dir.name)
+ gone = 0
+ for path in found:
+ if not is_real_entry(path):
+ continue
+ try:
+ if path.is_dir():
+ shutil.rmtree(path)
+ else:
+ path.unlink()
+ gone += 1
+ except OSError:
+ logger.warning("crash leftover not removed: %s", path.name)
+ return gone
diff --git a/app/pipeline/demucs_worker.py b/app/pipeline/demucs_worker.py
index dc679ca8..eb9e915d 100644
--- a/app/pipeline/demucs_worker.py
+++ b/app/pipeline/demucs_worker.py
@@ -9,6 +9,15 @@
device and only tears it down on cancel or a genuine failure.
Protocol:
+ - Once the model is loaded, before reading any job, the worker writes
+ "@@READY@@"
+ to stderr. Before it, on first use, torch.hub downloads the model (#125):
+ from its 'Downloading: "" to ' line to that file's 100% or
+ "@@READY@@", any "NN%" line is download progress, not separation, and the
+ parent holds that phase to a much shorter stall limit. A download that
+ fails after its tries writes "@@ERROR@@" instead, the
+ message starting with "model download failed:" and naming the file's
+ address and the folder to put it in by hand, and the worker exits(1).
- Parent writes one JSON line to stdin per job:
{"source": "", "job_dir": "", "shifts": 1}
- Normal demucs progress (tqdm, \\r-delimited "NN%" lines) streams to
@@ -38,7 +47,8 @@
import sys
from pathlib import Path
-from app.core.config import DEMUCS_MODEL
+from app.core.config import DEMUCS_MODEL, MODEL_DOWNLOAD_ATTEMPTS, TIMEOUT_MODEL_DOWNLOAD_READ
+from app.core.model_download import ModelDownloadError, bounded_downloads
from app.core.process import arm_parent_watchdog
@@ -92,9 +102,17 @@ def main() -> None:
from demucs.pretrained import get_model
- model = get_model(DEMUCS_MODEL)
+ try:
+ with bounded_downloads(TIMEOUT_MODEL_DOWNLOAD_READ, MODEL_DOWNLOAD_ATTEMPTS):
+ model = get_model(DEMUCS_MODEL)
+ except ModelDownloadError as e:
+ sys.stderr.write(f"@@ERROR@@{json.dumps(str(e))}\n")
+ sys.stderr.flush()
+ sys.exit(1)
model.eval()
model.cpu()
+ sys.stderr.write("@@READY@@\n")
+ sys.stderr.flush()
for line in sys.stdin:
line = line.strip()
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index 27ce23be..9b14f20b 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -411,15 +411,6 @@ def validate_playlist_url(url: str) -> str:
return f"https://www.youtube.com/playlist?list={list_id}"
-def is_playlist_url(url: str) -> bool:
- """Cheap check for the UI: would validate_playlist_url accept this?"""
- try:
- validate_playlist_url(url)
- except InvalidPlaylistURL:
- return False
- return True
-
-
def expand_playlist(url: str, limit: int) -> dict:
"""List a playlist's entries without downloading anything.
diff --git a/app/pipeline/eq_analysis.py b/app/pipeline/eq_analysis.py
new file mode 100644
index 00000000..1fddc3d2
--- /dev/null
+++ b/app/pipeline/eq_analysis.py
@@ -0,0 +1,256 @@
+"""Long-term average spectrum (LTAS) of a lane or a mix, loud passages only.
+
+This is what "Suggest EQ" compares with a target curve (eq_targets.json) and
+what the fitter (eq_fit.py) works on. Pure numpy; the audio arrives as 32-bit
+float stereo at ANALYSIS_RATE (ffmpeg decodes it, app/api/suggest.py).
+
+Method:
+
+- Frames of FRAME (16384 samples, 371 ms) every HOP (4410 samples, 100 ms),
+ Hann window, one real FFT per channel. 16384 points give 2.7 Hz bins, so
+ even the 20 Hz third-octave band (17.8 to 22.4 Hz) holds a bin.
+- Every frame's loudness is computed BS.1770-style from its spectrum: the
+ power of each bin weighted by the K-filter's magnitude (the RBJ high shelf
+ +4 dB at 1500 Hz and the high pass at 38 Hz that pyloudnorm uses to
+ re-derive BS.1770's 48 kHz filters at other rates), summed over the two
+ channels, -0.691 + 10 log10. Parseval makes that the windowed mean square
+ of the K-filtered frame, so the number is a loudness in LUFS. It is an
+ approximation of the standard's 400 ms rectangular blocks, used only to
+ choose frames; the loudness the UI shows comes from ffmpeg's ebur128.
+- **Gating (the rule that keeps silence, fades and bleed out):** the two
+ gates of ITU-R BS.1770-4/5, applied to these frames. Absolute: frames under
+ -70 LUFS are dropped. Relative: frames more than 10 LU under the energy mean
+ of the frames that passed the absolute gate are dropped. What remains is
+ the part of the song where the lane is actually playing; a guitar that only
+ enters in the chorus is measured on the chorus, not on the verses where its
+ stem carries a little bleed 20 dB down. The relative gate is the
+ standard's own rule, used here for the same reason it exists there.
+- One pass, constant memory: frames are accumulated into a histogram of
+ loudness in GATE_STEP_LU steps (the way libebur128 gates), each bin holding
+ the sum of its frames' power spectra. After the last frame the relative
+ threshold is known and the bins above it are summed. The result is the
+ mean power spectrum of the loud frames, at FFT resolution, so any EQ can be
+ applied to it exactly afterwards (eq_fit.py).
+- Third-octave bands: the 31 ISO 266 bands from 20 Hz to 20 kHz, exact
+ centres 1000 * 2^(n/3), edges at a sixth of an octave either side; a band's
+ level is the summed power of the bins inside it. Pink noise (equal power
+ per octave) reads flat in these bands.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import numpy as np
+
+ANALYSIS_RATE = 44100
+FRAME = 16384
+HOP = 4410
+GATE_ABS_LUFS = -70.0
+GATE_REL_LU = -10.0
+GATE_STEP_LU = 0.25
+_GATE_TOP_LUFS = 10.0
+_GATE_BINS = int((_GATE_TOP_LUFS - GATE_ABS_LUFS) / GATE_STEP_LU)
+
+# ISO 266 nominal centre frequencies, 20 Hz to 20 kHz (labels for people).
+NOMINAL_HZ = (
+ 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800,
+ 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
+) # fmt: skip
+N_BANDS = len(NOMINAL_HZ)
+# Exact base-2 centres: 1000 * 2^(n/3), n = -17..13.
+CENTRES_HZ = tuple(1000.0 * 2.0 ** (n / 3.0) for n in range(-17, 14))
+_EDGE = 2.0 ** (1.0 / 6.0)
+_TINY = 1e-30
+
+
+def bin_freqs(rate: int = ANALYSIS_RATE, frame: int = FRAME) -> np.ndarray:
+ return np.fft.rfftfreq(frame, 1.0 / rate)
+
+
+def _rbj_mag2(f: np.ndarray, rate: float, kind: str, f0: float, q: float, gain_db: float = 0.0):
+ """|H|^2 of an RBJ cookbook biquad at the frequencies ``f`` (Hz)."""
+ a = 10.0 ** (gain_db / 40.0)
+ w0 = 2.0 * math.pi * f0 / rate
+ cw, sw = math.cos(w0), math.sin(w0)
+ alpha = sw / (2.0 * q)
+ if kind == "highshelf":
+ k = 2.0 * math.sqrt(a) * alpha
+ b = (a * ((a + 1) + (a - 1) * cw + k), -2 * a * ((a - 1) + (a + 1) * cw),
+ a * ((a + 1) + (a - 1) * cw - k)) # fmt: skip
+ den = ((a + 1) - (a - 1) * cw + k, 2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - k)
+ else: # highpass
+ b = ((1 + cw) / 2, -(1 + cw), (1 + cw) / 2)
+ den = (1 + alpha, -2 * cw, 1 - alpha)
+ z = np.exp(-1j * 2.0 * np.pi * np.asarray(f, dtype=np.float64) / rate)
+ num = b[0] + b[1] * z + b[2] * z * z
+ dd = den[0] + den[1] * z + den[2] * z * z
+ return np.abs(num / dd) ** 2
+
+
+def k_weight2(f: np.ndarray, rate: float = ANALYSIS_RATE) -> np.ndarray:
+ """|K(f)|^2 of BS.1770's K-weighting at ``rate`` (pyloudnorm's design)."""
+ shelf = _rbj_mag2(
+ f, rate, "highshelf", 1681.974450955533, 0.7071752369554196, 3.999843853973347
+ )
+ hp = _rbj_mag2(f, rate, "highpass", 38.13547087602444, 0.5003270373238773)
+ return shelf * hp
+
+
+def band_index(freqs: np.ndarray) -> np.ndarray:
+ """For every FFT bin, the third-octave band it belongs to (-1: none)."""
+ idx = np.full(freqs.shape, -1, dtype=np.int64)
+ for b, fc in enumerate(CENTRES_HZ):
+ lo, hi = fc / _EDGE, fc * _EDGE
+ idx[(freqs >= lo) & (freqs < hi)] = b
+ return idx
+
+
+_BIN_FREQS = bin_freqs()
+_BAND_OF_BIN = band_index(_BIN_FREQS)
+_K2 = k_weight2(_BIN_FREQS)
+_WINDOW = np.hanning(FRAME).astype(np.float64)
+# One-sided spectrum: every bin but DC and Nyquist stands for two.
+_SIDES = np.full(FRAME // 2 + 1, 2.0)
+_SIDES[0] = 1.0
+_SIDES[-1] = 1.0
+# |X|^2 * _SCALE is the bin's share of the windowed mean square (Parseval).
+_SCALE = _SIDES / (FRAME * float(np.sum(_WINDOW**2)))
+
+
+def bands_db(power: np.ndarray, band_of_bin: np.ndarray = _BAND_OF_BIN) -> np.ndarray:
+ """Third-octave band levels (dB) of a power spectrum at FFT resolution."""
+ sums = np.bincount(
+ band_of_bin[band_of_bin >= 0], weights=power[band_of_bin >= 0], minlength=N_BANDS
+ )
+ return 10.0 * np.log10(sums[:N_BANDS] + _TINY)
+
+
+def loudness_of_power(power: np.ndarray) -> float:
+ """BS.1770-style loudness (LUFS) of a mean power spectrum."""
+ return -0.691 + 10.0 * math.log10(float(np.sum(power * _K2)) + _TINY)
+
+
+@dataclass
+class Spectrum:
+ """The gated long-term average of a signal."""
+
+ power: np.ndarray # mean power per FFT bin of the loud frames (both channels summed)
+ frames: int # frames analysed
+ gated_frames: int # frames that passed both gates
+ seconds: float # length of the audio analysed
+
+ @property
+ def silent(self) -> bool:
+ return self.gated_frames == 0
+
+ @property
+ def bands(self) -> np.ndarray:
+ return bands_db(self.power)
+
+ @property
+ def gated_seconds(self) -> float:
+ return self.gated_frames * HOP / ANALYSIS_RATE
+
+
+class SpectrumAccumulator:
+ """Feed interleaved stereo float32 samples in any chunk sizes; ``result()``
+ gives the gated average. Memory: the histogram, about 21 MB, plus one
+ frame of carry-over."""
+
+ def __init__(self) -> None:
+ self._hist = np.zeros((_GATE_BINS, FRAME // 2 + 1), dtype=np.float64)
+ self._count = np.zeros(_GATE_BINS, dtype=np.int64)
+ self._energy = np.zeros(_GATE_BINS, dtype=np.float64)
+ self._buf = np.zeros((0, 2), dtype=np.float32)
+ self._frames = 0
+ self._samples = 0
+
+ def feed(self, interleaved: np.ndarray) -> None:
+ x = np.asarray(interleaved, dtype=np.float32).reshape(-1, 2)
+ self._samples += x.shape[0]
+ buf = np.concatenate([self._buf, x]) if self._buf.size else x
+ n = buf.shape[0]
+ if n < FRAME:
+ self._buf = buf
+ return
+ starts = range(0, n - FRAME + 1, HOP)
+ for s in starts:
+ self._frame(buf[s : s + FRAME])
+ nxt = starts[-1] + HOP
+ self._buf = buf[nxt:].copy()
+
+ def _frame(self, frame: np.ndarray) -> None:
+ self._frames += 1
+ spec = np.fft.rfft(frame.astype(np.float64) * _WINDOW[:, None], axis=0)
+ power = (np.abs(spec[:, 0]) ** 2 + np.abs(spec[:, 1]) ** 2) * _SCALE
+ ms_k = float(np.sum(power * _K2))
+ if ms_k <= 0.0:
+ return
+ lufs = -0.691 + 10.0 * math.log10(ms_k)
+ if lufs <= GATE_ABS_LUFS:
+ return
+ b = min(_GATE_BINS - 1, int((lufs - GATE_ABS_LUFS) / GATE_STEP_LU))
+ self._hist[b] += power
+ self._count[b] += 1
+ self._energy[b] += ms_k
+
+ def finish(self) -> None:
+ """Analyse the tail: the last partial frame, zero padded, when it is at
+ least a quarter of a frame long (so a short sound is not lost)."""
+ rest = self._buf.shape[0]
+ if rest and (self._frames == 0 or rest >= FRAME // 4):
+ pad = np.zeros((FRAME, 2), dtype=np.float32)
+ pad[:rest] = self._buf[:FRAME]
+ self._frame(pad)
+ self._buf = np.zeros((0, 2), dtype=np.float32)
+
+ def result(self) -> Spectrum:
+ seconds = self._samples / ANALYSIS_RATE
+ total = int(self._count.sum())
+ if total == 0:
+ return Spectrum(np.zeros(FRAME // 2 + 1), self._frames, 0, seconds)
+ mean_energy = float(self._energy.sum()) / total
+ threshold = -0.691 + 10.0 * math.log10(mean_energy) + GATE_REL_LU
+ # A histogram bin is in when its centre is above the threshold.
+ centres = GATE_ABS_LUFS + (np.arange(_GATE_BINS) + 0.5) * GATE_STEP_LU
+ keep = centres > threshold
+ gated = int(self._count[keep].sum())
+ if gated == 0:
+ return Spectrum(np.zeros(FRAME // 2 + 1), self._frames, 0, seconds)
+ power = self._hist[keep].sum(axis=0) / gated
+ return Spectrum(power, self._frames, gated, seconds)
+
+
+def analyse(samples: np.ndarray) -> Spectrum:
+ """The gated LTAS of a whole (n, 2) or interleaved float signal at
+ ANALYSIS_RATE. Tests and scripts; the server streams into the accumulator."""
+ acc = SpectrumAccumulator()
+ acc.feed(np.asarray(samples, dtype=np.float32).reshape(-1))
+ acc.finish()
+ return acc.result()
+
+
+# ---------------------------------------------------------------------------
+# Cache form: the gated power spectrum in dB, three decimals (about 65 KB).
+# ---------------------------------------------------------------------------
+
+
+def spectrum_to_json(s: Spectrum) -> dict:
+ db = 10.0 * np.log10(s.power + _TINY)
+ return {
+ "frames": s.frames,
+ "gated_frames": s.gated_frames,
+ "seconds": round(s.seconds, 3),
+ "power_db": [round(float(v), 3) for v in db],
+ }
+
+
+def spectrum_from_json(data: dict) -> Spectrum:
+ db = np.asarray(data["power_db"], dtype=np.float64)
+ if db.shape != (FRAME // 2 + 1,) or not np.all(np.isfinite(db)):
+ raise ValueError("bad cached spectrum")
+ power = 10.0 ** (db / 10.0)
+ power[db <= -299.0] = 0.0
+ return Spectrum(power, int(data["frames"]), int(data["gated_frames"]), float(data["seconds"]))
diff --git a/app/pipeline/eq_fit.py b/app/pipeline/eq_fit.py
new file mode 100644
index 00000000..24c9bba8
--- /dev/null
+++ b/app/pipeline/eq_fit.py
@@ -0,0 +1,693 @@
+"""Suggest EQ: turn "measured spectrum minus target" into a few ordinary EQ
+bands, and pick a master loudness preset.
+
+This is the fitter agreed with the mastering report (01-mastering.md 5.5); it
+works for a lane as well as for the master. Its output
+is nothing but the E1 EQ descriptor's bands (bell, shelves, low cut, the
+Keys panel's tilt) with the engine's own maths (fx_filters.py), so a
+suggestion is an EQ the user can see, drag, preview, keep or undo.
+
+The fit, on the 31 third-octave bands (eq_analysis.py):
+
+1. Error = target - measured, with the overall level removed (the weighted
+ mean of the difference: only the shape is compared; loudness is the
+ output gain's job, below). Clipped to +-15 dB, then smoothed over three
+ neighbouring bands (2/3 octave), so a narrow resonance or one loud note
+ is not chased: suggestions are broad, the kind a person can judge.
+2. A tolerance range around the target (TOL_DB: +-1 dB in the mids, up to
+ +-5 dB at the extremes, where songs differ most): only what lies outside
+ it is asked for (a soft threshold), so a suggestion moves a lane into the
+ normal range rather than onto one line. Too little bass is tolerated twice
+ as much as too much (Elowsson and Friberg: deviations below the mean in
+ the bass are "generally less alarming").
+3. Weights: a band counts fully where the signal has energy (within 30 dB
+ of its loudest band) and fades to nothing 45 dB down; the extremes
+ (20 to 31.5 Hz, 16 and 20 kHz) count less. No boost is asked where the
+ signal lacks 12 to 20 dB or more against the target: then the content is
+ missing, not dull (a song with no bass instrument is not given one, an
+ encoder's cut-off is not lifted). Cuts are allowed anywhere.
+4. Greedy: of every candidate band the layout allows (each type at the
+ nominal third-octave frequencies in its range and a few Q values), the
+ one that lowers the cost most is added, its gain from weighted least
+ squares on its per-dB shape, clamped to +-SUGGEST_GAIN_MAX, rounded to
+ 0.5 dB and checked one step either way. Cost = weighted mean square of
+ what is still outside the tolerance (level removed) plus LAMBDA *
+ (gain/6)^2 per band, so a big band must earn its size. A band is added
+ only while it improves the cost by at least max(MU_ABS, MU_REL * cost);
+ after each addition every band is refined (frequency one step either way,
+ Q, gain). Bands of one type stay an octave apart, the whole curve stays
+ within +-COMBINED_MAX_DB from 40 Hz up, and bands under MIN_GAIN_DB are
+ dropped.
+5. Loudness-neutral by design: the output gain is minus the change in
+ K-weighted (BS.1770) loudness the bands cause on the measured, gated
+ spectrum, so the suggestion is not simply louder. The estimate and what
+ is left of it are reported; tests render through ffmpeg and measure it.
+
+Layouts: "free" (the graphical panels: any number of bells and shelves up to
+the band limit, plus a gentle low cut), "master" (the same, low cut only in
+the sub range), and the fixed panels "bass", "guitar" and "keys", whose
+knobs are fixed bands (static/js/eq/eqPedalMath.js, mirrored here): each
+slot is used at most once, in its own frequency range, and the result maps
+back onto the knobs.
+"""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass, field
+
+import numpy as np
+
+from app.pipeline.eq_analysis import (
+ _BIN_FREQS,
+ _K2,
+ ANALYSIS_RATE,
+ CENTRES_HZ,
+ N_BANDS,
+ NOMINAL_HZ,
+ bands_db,
+)
+from app.pipeline.fx_filters import Band, band_sections, section_coefficients
+
+SUGGEST_GAIN_MAX = 6.0
+MIN_GAIN_DB = 1.0
+GAIN_STEP = 0.5
+LAMBDA = 0.15
+MU_ABS = 0.1
+MU_REL = 0.06
+ERROR_CLIP_DB = 15.0
+OUT_MAX_DB = 12.0
+MAX_BANDS_LANE = 5
+MAX_BANDS_MASTER = 6
+REFINE_ROUNDS = 2
+# Bands of one type at least this far apart (octaves).
+CROWD_OCT = 1.0
+# The combined suggestion stays within this, from 40 Hz up (dB).
+COMBINED_MAX_DB = 7.0
+
+# The tolerance range around a target (dB, either side), per band: a
+# deviation inside it is normal and asks for nothing. Wider in the bass and
+# the top octave, where songs differ most (Elowsson and Friberg 2017, figure 3:
+# the standard deviation of the LTAS across 12,345 tracks is highest at low
+# and high frequencies and lowest from 200 Hz to 4 kHz).
+TOL_DB = np.array(
+ [5.0, 5.0, 5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
+ 1.0, 1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.5, 2.5, 3.0, 3.5, 4.0, 5.0]
+) # fmt: skip
+# "Deviations below the mean in the bass are generally less alarming" (the
+# same paper): less bass than the target is tolerated this much more.
+BASS_LOW_HZ = 125.0
+BASS_SHORT_FACTOR = 2.0
+# Boosts only where there is something to lift: how far under the target a
+# band may be and still be boosted (dB, level removed).
+BOOST_DEFICIT_FULL_DB = 12.0
+BOOST_DEFICIT_NONE_DB = 20.0
+
+FREE_Q = (0.7, 1.0, 1.4, 2.0)
+PEDAL_Q = (1.0, 1.4, 2.0)
+
+# Band weights before the signal's own (index = band; 20, 25, 31.5 Hz and
+# 16, 20 kHz count less).
+_BASE_W = np.ones(N_BANDS)
+_BASE_W[[0, 1]] = 0.25
+_BASE_W[2] = 0.5
+_BASE_W[29] = 0.5
+_BASE_W[30] = 0.25
+
+# Points each band's response is evaluated at (power-averaged): four inside
+# the band, so a band's level after an EQ is close to what the fine spectrum
+# gives (checked exactly on the fine spectrum afterwards).
+_SUB = np.array([-1 / 8, -1 / 24, 1 / 24, 1 / 8])
+_EVAL_F = np.array([fc * 2.0**s for fc in CENTRES_HZ for s in _SUB])
+
+
+def _nominal_between(lo: float, hi: float) -> tuple[float, ...]:
+ return tuple(float(f) for f in NOMINAL_HZ if lo <= f <= hi)
+
+
+@dataclass(frozen=True)
+class Slot:
+ """One place a suggested band may go. ``reuse``: any number of bands of
+ this kind (the graphical panels); otherwise one at most (a knob)."""
+
+ id: str
+ type: str
+ freqs: tuple[float, ...]
+ qs: tuple[float, ...] = (0.707,)
+ gain_max: float = SUGGEST_GAIN_MAX
+ reuse: bool = False
+ dynamic_shelf: bool = False # the Bass EQ's low shelf: its corner moves with the gain
+
+ @property
+ def gainless(self) -> bool:
+ return self.type in ("lowcut", "highcut")
+
+
+# The Bass EQ's low shelf corner for a gain (eqPedalMath.dynamicShelfFreq).
+def dynamic_shelf_freq(g: float, f_min: float = 75.0, f_max: float = 400.0) -> float:
+ a = abs(g)
+ if a <= 3:
+ return f_min
+ return round(f_min * (f_max / f_min) ** ((min(a, 15.0) - 3) / 12.0), 1)
+
+
+_FREE = (
+ Slot("bell", "bell", _nominal_between(40, 16000), FREE_Q, reuse=True),
+ # One shelf of each kind: two stacked shelves read as one, less clearly.
+ Slot("lowshelf", "lowshelf", _nominal_between(63, 250)),
+ Slot("highshelf", "highshelf", _nominal_between(2000, 10000)),
+)
+LAYOUTS: dict[str, tuple[Slot, ...]] = {
+ "free": (*_FREE, Slot("lowcut", "lowcut", _nominal_between(25, 125))),
+ "master": (*_FREE, Slot("lowcut", "lowcut", _nominal_between(20, 31.5))),
+ # static/js/eq/eqPedalMath.js PEDALS.bass
+ "bass": (
+ Slot("hpf", "lowcut", _nominal_between(25, 63)),
+ Slot("ls", "lowshelf", (75.0,), dynamic_shelf=True),
+ Slot("low", "bell", _nominal_between(35, 500), PEDAL_Q),
+ Slot("mid", "bell", _nominal_between(100, 1600), PEDAL_Q),
+ Slot("high", "bell", _nominal_between(375, 9700), PEDAL_Q),
+ Slot("hs", "highshelf", (1000.0,)),
+ ),
+ # PEDALS.guitar
+ "guitar": (
+ Slot("hpf", "lowcut", _nominal_between(40, 160)),
+ Slot("ls", "lowshelf", (200.0,)),
+ Slot("low", "bell", _nominal_between(35, 500), PEDAL_Q),
+ Slot("mid", "bell", _nominal_between(250, 5000), PEDAL_Q),
+ Slot("high", "bell", _nominal_between(1000, 16000), PEDAL_Q),
+ Slot("hs", "highshelf", (1000.0,)),
+ ),
+ # KEYS (lowcut 20-300, body 120 shelf, warmth 250 Q 0.8, presence 2-5k Q 1,
+ # air 10k shelf, tone tilt at 1 kHz +-6)
+ "keys": (
+ Slot("lowcut", "lowcut", _nominal_between(40, 160)),
+ Slot("body", "lowshelf", (120.0,)),
+ Slot("warmth", "bell", (250.0,), (0.8,)),
+ Slot("presence", "bell", _nominal_between(2000, 5000), (1.0,)),
+ Slot("air", "highshelf", (10000.0,)),
+ Slot("tone", "tilt", (1000.0,)),
+ ),
+}
+FIXED_LAYOUTS = ("bass", "guitar", "keys")
+
+
+@dataclass(frozen=True)
+class FitBand:
+ slot: str
+ band: Band
+
+ def to_json(self) -> dict:
+ b = self.band
+ return {"slot": self.slot, "type": b.type, "f": b.f, "g": b.g, "q": b.q, "slope": b.slope}
+
+
+@dataclass
+class FitResult:
+ bands: list[FitBand] = field(default_factory=list)
+ error_db: np.ndarray = field(
+ default_factory=lambda: np.zeros(N_BANDS)
+ ) # smoothed target - measured
+ weights: np.ndarray = field(default_factory=lambda: np.zeros(N_BANDS))
+
+
+# ---------------------------------------------------------------------------
+# Responses
+# ---------------------------------------------------------------------------
+
+
+def _sections_mag2(band: Band, freqs: np.ndarray, fs: float) -> np.ndarray:
+ """|H|^2 of one band at ``freqs`` (the engine's own sections)."""
+ out = np.ones(freqs.shape)
+ z = np.exp(-1j * 2.0 * np.pi * freqs / fs)
+ for sec in band_sections(band):
+ b0, b1, b2, a1, a2 = section_coefficients(sec, fs)
+ h = (b0 + b1 * z + b2 * z * z) / (1.0 + a1 * z + a2 * z * z)
+ out *= np.abs(h) ** 2
+ return out
+
+
+def eq_mag2(bands: list[Band], freqs: np.ndarray, out_db: float = 0.0, fs: float = ANALYSIS_RATE):
+ """|H|^2 of a whole EQ (bands and output gain) at ``freqs``."""
+ m = np.full(freqs.shape, 10.0 ** (out_db / 10.0))
+ for b in bands:
+ m *= _sections_mag2(b, freqs, fs)
+ return m
+
+
+def band_response_db(band: Band) -> np.ndarray:
+ """A band's response on the 31 bands (power average of 4 points in each)."""
+ m = _sections_mag2(band, _EVAL_F, ANALYSIS_RATE).reshape(N_BANDS, len(_SUB))
+ return 10.0 * np.log10(np.maximum(m.mean(axis=1), 1e-30))
+
+
+def apply_eq(power: np.ndarray, bands: list[Band], out_db: float = 0.0) -> np.ndarray:
+ """A fine (FFT-bin) power spectrum after an EQ: exact, at the analysis rate."""
+ return power * eq_mag2(bands, _BIN_FREQS, out_db)
+
+
+def loudness_change_db(power: np.ndarray, bands: list[Band], out_db: float = 0.0) -> float:
+ """How much an EQ changes the K-weighted loudness of ``power`` (dB/LU)."""
+ before = float(np.sum(power * _K2))
+ if before <= 0:
+ return 0.0
+ after = float(np.sum(apply_eq(power, bands, out_db) * _K2))
+ return 10.0 * math.log10(max(after, 1e-30) / before)
+
+
+# ---------------------------------------------------------------------------
+# The fit
+# ---------------------------------------------------------------------------
+
+
+def weights_for(measured_db: np.ndarray) -> np.ndarray:
+ peak = float(np.max(measured_db))
+ audible = np.clip((measured_db - (peak - 45.0)) / 15.0, 0.0, 1.0)
+ return _BASE_W * audible
+
+
+def _demean(x: np.ndarray, w: np.ndarray) -> np.ndarray:
+ sw = float(w.sum())
+ return x - (float(w @ x) / sw if sw > 0 else 0.0)
+
+
+def _cost(resid: np.ndarray, w: np.ndarray) -> float:
+ sw = float(w.sum())
+ if sw <= 0:
+ return 0.0
+ r = _demean(resid, w)
+ return float(w @ (r * r)) / sw
+
+
+def _tcost(
+ resid: np.ndarray, w: np.ndarray, boost_ok: np.ndarray | None = None, scale: float = 1.0
+) -> float:
+ """The fit's cost: weighted mean square of what lies outside the
+ tolerance range, after the level is removed."""
+ sw = float(w.sum())
+ if sw <= 0:
+ return 0.0
+ r = tolerated_error(_demean(resid, w), boost_ok, scale)
+ return float(w @ (r * r)) / sw
+
+
+def outside_rms_db(diff: np.ndarray, w: np.ndarray) -> float:
+ """RMS (dB) of the deviation outside the tolerance range."""
+ return math.sqrt(_tcost(np.asarray(diff, dtype=np.float64), w))
+
+
+def weighted_rms_db(diff: np.ndarray, w: np.ndarray) -> float:
+ """RMS deviation (dB) with the level removed: the distance to a target."""
+ return math.sqrt(_cost(np.asarray(diff, dtype=np.float64), w))
+
+
+def smoothed_error(measured_db: np.ndarray, target_db: np.ndarray, w: np.ndarray) -> np.ndarray:
+ """Target minus measured, level removed, clipped and smoothed (2/3 octave)."""
+ e = _demean(np.asarray(target_db, float) - np.asarray(measured_db, float), w)
+ e = np.clip(e, -ERROR_CLIP_DB, ERROR_CLIP_DB)
+ padded = np.concatenate([[e[0]], e, [e[-1]]])
+ return 0.25 * padded[:-2] + 0.5 * padded[1:-1] + 0.25 * padded[2:]
+
+
+def tolerated_error(
+ error: np.ndarray, boost_ok: np.ndarray | None = None, scale: float = 1.0
+) -> np.ndarray:
+ """The part of the error outside the tolerance range (soft threshold):
+ inside the range nothing is asked for, outside only the excess. Below
+ BASS_LOW_HZ a lack of bass is tolerated BASS_SHORT_FACTOR times more.
+ ``boost_ok`` (0..1 per band) scales what a boost may be asked for."""
+ tol = TOL_DB * scale
+ tol_up = tol.copy() # error > 0: measured under the target (a boost)
+ tol_up[np.asarray(CENTRES_HZ) < BASS_LOW_HZ] *= BASS_SHORT_FACTOR
+ up = np.maximum(0.0, error - tol_up)
+ if boost_ok is not None:
+ up = up * boost_ok
+ return np.where(error > 0, up, np.minimum(0.0, error + tol))
+
+
+def boost_allowed(measured_db: np.ndarray, target_db) -> np.ndarray:
+ """Where a boost may be suggested: not where the signal lacks so much
+ against the target that the content is missing rather than dull (a song
+ with no bass instrument, an encoder's cut-off, a stem's empty octaves).
+ Full up to BOOST_DEFICIT_FULL_DB under the target (level removed,
+ smoothed), none from BOOST_DEFICIT_NONE_DB."""
+ m = np.asarray(measured_db, dtype=float)
+ deficit = smoothed_error(m, target_db, weights_for(m))
+ span = BOOST_DEFICIT_NONE_DB - BOOST_DEFICIT_FULL_DB
+ return np.clip((BOOST_DEFICIT_NONE_DB - deficit) / span, 0.0, 1.0)
+
+
+def _make_band(slot: Slot, f: float, g: float, q: float) -> Band:
+ if slot.dynamic_shelf:
+ f = dynamic_shelf_freq(g)
+ return Band(slot.type, float(f), float(g), float(q), 12)
+
+
+def _round_gain(g: float, gmax: float) -> float:
+ g = max(-gmax, min(gmax, g))
+ return round(round(g / GAIN_STEP) * GAIN_STEP, 2)
+
+
+class _Fitter:
+ def __init__(self, error: np.ndarray, w: np.ndarray, boost_ok: np.ndarray, scale: float):
+ self.scale = scale
+ self.e = error
+ self.w = w
+ self.boost_ok = boost_ok
+ self.cache: dict[tuple, np.ndarray] = {}
+
+ def resp(self, slot: Slot, f: float, g: float, q: float) -> np.ndarray:
+ key = (slot.id, slot.type, f, g, q, slot.dynamic_shelf)
+ r = self.cache.get(key)
+ if r is None:
+ r = band_response_db(_make_band(slot, f, g, q))
+ self.cache[key] = r
+ return r
+
+ def penalty(self, g: float) -> float:
+ return LAMBDA * (g / 6.0) ** 2
+
+ def best_for(self, slot: Slot, f: float, q: float, rest: np.ndarray):
+ """Best gain for this band shape given the response of the others.
+ Returns (cost, gain, response) or None."""
+ target = self.e - rest
+ w = self.w
+ if slot.gainless:
+ r = self.resp(slot, f, 0.0, q)
+ if _too_much(rest + r):
+ return None
+ return _tcost(target - r, w, self.boost_ok, self.scale), 0.0, r
+ # Per-dB shape at +-3 dB (the dB response is close to linear in the
+ # gain, and odd for these filters): least squares on the demeaned
+ # shapes against what is outside the tolerance, then the exact cost
+ # at that gain and its neighbours on the 0.5 dB grid.
+ shape = (self.resp(slot, f, 3.0, q) - self.resp(slot, f, -3.0, q)) / 6.0
+ s = _demean(shape, w)
+ tdm = tolerated_error(_demean(target, w), self.boost_ok, self.scale)
+ denom = float(w @ (s * s)) + LAMBDA / 36.0 * float(w.sum())
+ if denom <= 0:
+ return None
+ g0 = _round_gain(float(w @ (s * tdm)) / denom, slot.gain_max)
+ best = None
+ for d in (-GAIN_STEP, 0.0, GAIN_STEP):
+ g = _round_gain(g0 + d, slot.gain_max)
+ if abs(g) < MIN_GAIN_DB:
+ continue
+ r = self.resp(slot, f, g, q)
+ if _too_much(rest + r):
+ continue
+ cost = _tcost(target - r, w, self.boost_ok, self.scale) + self.penalty(g)
+ if best is None or cost < best[0]:
+ best = (cost, g, r)
+ return best
+
+
+def _too_much(curve: np.ndarray) -> bool:
+ """The whole suggestion's curve may not pass +-COMBINED_MAX_DB at any band
+ from 40 Hz up (two overlapping +6 dB bands would make +12): a low cut is
+ allowed to go deeper below 40 Hz, that is what it is for."""
+ return bool(np.max(np.abs(curve[3:])) > COMBINED_MAX_DB)
+
+
+def _crowded(chosen, slot: Slot, f: float, order: list[str] | None = None) -> bool:
+ """Two bands of one type closer than CROWD_OCT would add up past the
+ gain limit (two +6 dB shelves at 4 kHz are a +12 dB shelf): not allowed.
+ On a fixed panel (``order``: its slot ids) the bell knobs also keep their
+ order, so "low" never sits above "mid"."""
+ for c in chosen:
+ if c[0].type == slot.type and abs(math.log2(c[1] / f)) < CROWD_OCT - 1e-6:
+ return True
+ if order and slot.type == "bell" and c[0].type == "bell" and c[0].id != slot.id:
+ mine, theirs = order.index(slot.id), order.index(c[0].id)
+ if (mine < theirs and f >= c[1]) or (mine > theirs and f <= c[1]):
+ return True
+ return False
+
+
+def fit_eq(
+ measured_db,
+ target_db,
+ layout: str = "free",
+ max_bands: int = MAX_BANDS_LANE,
+ tolerance: float = 1.0,
+ boost_ref=None,
+) -> FitResult:
+ """Fit a few bands of ``layout`` so the measured third-octave spectrum
+ moves towards the target. Both are 31 levels in dB (any offset).
+ ``tolerance`` scales the tolerance range (0: fit the line itself; tests).
+ ``boost_ref``: the levels that decide where a boost may go (default the
+ measured ones). A fit on top of an EQ passes the lane's raw levels, so a
+ boost already applied does not make room for more (no ratchet)."""
+ measured = np.asarray(measured_db, dtype=np.float64)
+ w = weights_for(measured)
+ error = smoothed_error(measured, target_db, w)
+ result = FitResult(error_db=error, weights=w)
+ slots = LAYOUTS[layout]
+ order = [sl.id for sl in slots] if layout in FIXED_LAYOUTS else None
+ boost_ok = boost_allowed(
+ measured if boost_ref is None else np.asarray(boost_ref, float), target_db
+ )
+ fitter = _Fitter(error, w, boost_ok, tolerance)
+ chosen: list[tuple[Slot, float, float, float, np.ndarray]] = [] # slot, f, g, q, resp
+
+ def total(skip: int = -1) -> np.ndarray:
+ r = np.zeros(N_BANDS)
+ for k, c in enumerate(chosen):
+ if k != skip:
+ r += c[4]
+ return r
+
+ def full_cost() -> float:
+ return _tcost(error - total(), w, boost_ok, tolerance) + sum(
+ fitter.penalty(c[2]) for c in chosen
+ )
+
+ current = full_cost()
+ while len(chosen) < max_bands:
+ used = {c[0].id for c in chosen}
+ rest = total()
+ best = None
+ for slot in slots:
+ if not slot.reuse and slot.id in used:
+ continue
+ for f in slot.freqs:
+ if _crowded(chosen, slot, f, order):
+ continue
+ for q in slot.qs:
+ got = fitter.best_for(slot, f, q, rest)
+ if got is None:
+ continue
+ cost = got[0] + sum(fitter.penalty(c[2]) for c in chosen)
+ if best is None or cost < best[0]:
+ best = (cost, slot, f, got[1], q, got[2])
+ if best is None or current - best[0] < max(MU_ABS, MU_REL * current):
+ break
+ chosen.append((best[1], best[2], best[3], best[4], best[5]))
+ current = best[0]
+ for _ in range(REFINE_ROUNDS):
+ for k in range(len(chosen)):
+ slot, f, g, q, _r = chosen[k]
+ rest = total(skip=k)
+ others = sum(fitter.penalty(c[2]) for j, c in enumerate(chosen) if j != k)
+ i = slot.freqs.index(f)
+ near = slot.freqs[max(0, i - 1) : i + 2]
+ cand = None
+ others_list = chosen[:k] + chosen[k + 1 :]
+ for f2 in near:
+ if _crowded(others_list, slot, f2, order):
+ continue
+ for q2 in slot.qs:
+ got = fitter.best_for(slot, f2, q2, rest)
+ if got is None:
+ continue
+ if cand is None or got[0] < cand[0]:
+ cand = (got[0], f2, got[1], q2, got[2])
+ if cand is not None and cand[0] + others < full_cost() - 1e-9:
+ chosen[k] = (slot, cand[1], cand[2], cand[3], cand[4])
+ current = full_cost()
+
+ # Bands that refining left tiny are not worth a handle.
+ kept = [c for c in chosen if c[0].gainless or abs(c[2]) >= MIN_GAIN_DB]
+ ordered = sorted(kept, key=lambda c: c[1])
+ if order:
+ ordered = sorted(kept, key=lambda c: order.index(c[0].id))
+ result.bands = [FitBand(c[0].id, _make_band(c[0], c[1], c[2], c[3])) for c in ordered]
+ return result
+
+
+def neutral_out_db(power: np.ndarray, bands: list[Band], base_out: float = 0.0) -> float:
+ """The output gain that keeps ``power``'s K-weighted loudness where it
+ was with ``base_out``, after ``bands`` (rounded to 0.1 dB)."""
+ change = loudness_change_db(power, bands, 0.0)
+ out = base_out - change
+ return round(max(-OUT_MAX_DB, min(OUT_MAX_DB, out)), 1)
+
+
+# ---------------------------------------------------------------------------
+# Plain-language reasons
+# ---------------------------------------------------------------------------
+
+
+def region_of(f: float) -> str:
+ if f < 60:
+ return "sub"
+ if f < 250:
+ return "bass"
+ if f < 600:
+ return "lowmid"
+ if f < 2000:
+ return "mid"
+ if f < 5000:
+ return "uppermid"
+ if f < 10000:
+ return "high"
+ return "air"
+
+
+def _nearest_band(f: float) -> int:
+ return int(np.argmin(np.abs(np.log2(np.asarray(CENTRES_HZ) / f))))
+
+
+def _mean_dev(dev: np.ndarray, w: np.ndarray, mask: np.ndarray) -> float:
+ ww = w * mask
+ if ww.sum() <= 0:
+ return float(dev[mask].mean()) if mask.any() else 0.0
+ return float(ww @ dev) / float(ww.sum())
+
+
+def band_reasons(fit: FitResult) -> list[dict]:
+ """One reason per band, from the numbers: how far the measured spectrum
+ is from the target where the band acts, and what the band does. ``dev``
+ is measured minus target (positive: too much there)."""
+ dev = -fit.error_db
+ fc = np.asarray(CENTRES_HZ)
+ out = []
+ for fb in fit.bands:
+ b = fb.band
+ f = b.f
+ if b.type == "lowcut":
+ mask = fc < f
+ out.append(
+ {
+ "key": "suggest.why.lowCut",
+ "vars": {"f": f, "dev": round(_mean_dev(dev, fit.weights, mask), 1)},
+ }
+ )
+ continue
+ if b.type == "lowshelf":
+ mask = fc <= f * 1.5
+ key = "suggest.why.lowShelfCut" if b.g < 0 else "suggest.why.lowShelfBoost"
+ elif b.type == "highshelf":
+ mask = fc >= f / 1.5
+ key = "suggest.why.highShelfCut" if b.g < 0 else "suggest.why.highShelfBoost"
+ elif b.type == "tilt":
+ mask = fc >= f
+ key = "suggest.why.tiltDarker" if b.g < 0 else "suggest.why.tiltBrighter"
+ else:
+ i = _nearest_band(f)
+ mask = np.zeros(N_BANDS, dtype=bool)
+ mask[max(0, i - 1) : i + 2] = True
+ key = "suggest.why.bellCut" if b.g < 0 else "suggest.why.bellBoost"
+ out.append(
+ {
+ "key": key,
+ "vars": {
+ "f": f,
+ "g": b.g,
+ "dev": round(_mean_dev(dev, fit.weights, mask), 1),
+ "region": region_of(f),
+ },
+ }
+ )
+ return out
+
+
+# ---------------------------------------------------------------------------
+# The master preset
+# ---------------------------------------------------------------------------
+
+# How much the limiter may take off the loudest peaks before a preset asks
+# too much of the material (dB). Past it a quieter target is suggested.
+LIMIT_ALLOWANCE_DB = 4.0
+# Material this dense (peak-to-loudness ratio under DENSE_PLR) and already
+# this loud is mastered loud on purpose: "Louder" keeps its character.
+DENSE_PLR = 11.0
+DENSE_LUFS = -11.0
+LOUDER_LUFS = -9.0
+CEILING_DBTP = -1.0
+CUSTOM_MIN_LUFS = -24.0
+
+
+def recommend_master(i: float | None, tp: float | None, target_lufs: float | None) -> dict:
+ """A master preset (masterMath.js MASTER_PRESETS) from the mix's
+ integrated loudness ``i`` and true peak ``tp``: never louder than the
+ material allows, i.e. never asking the limiter for more than
+ LIMIT_ALLOWANCE_DB on the loudest peaks. Returns the preset, its
+ settings, the estimated limiting and the reason (an i18n key and its
+ numbers)."""
+ want = -14.0 if target_lufs is None else float(target_lufs)
+ if i is None or tp is None:
+ return {
+ "preset": "gentle",
+ "target_lufs": None,
+ "ceiling_dbtp": CEILING_DBTP,
+ "comp": 0.0,
+ "limit_db": 0.0,
+ "why": {"key": "suggest.why.master.silent", "vars": {}},
+ }
+ plr = tp - i
+ # The loudest target whose peaks the limiter trims by at most the allowance.
+ allowed = CEILING_DBTP - plr + LIMIT_ALLOWANCE_DB
+
+ def limiting(target: float) -> float:
+ return round(max(0.0, target - CEILING_DBTP + plr), 1)
+
+ nums = {"i": round(i, 1), "plr": round(plr, 1)}
+ if plr < DENSE_PLR and i > DENSE_LUFS and allowed >= LOUDER_LUFS:
+ return {
+ "preset": "louder",
+ "target_lufs": LOUDER_LUFS,
+ "ceiling_dbtp": CEILING_DBTP,
+ "comp": 0.5,
+ "limit_db": limiting(LOUDER_LUFS),
+ "why": {"key": "suggest.why.master.louder", "vars": {**nums, "target": LOUDER_LUFS}},
+ }
+ if allowed >= want:
+ return {
+ "preset": "streaming",
+ "target_lufs": want,
+ "ceiling_dbtp": CEILING_DBTP,
+ "comp": 0.0,
+ "limit_db": limiting(want),
+ "why": {
+ "key": "suggest.why.master.streaming",
+ "vars": {**nums, "target": want, "limit": limiting(want)},
+ },
+ }
+ if allowed < CUSTOM_MIN_LUFS:
+ # Even the quietest target would squash the peaks: no loudness
+ # change at all, only the limiter against clipping.
+ return {
+ "preset": "gentle",
+ "target_lufs": None,
+ "ceiling_dbtp": CEILING_DBTP,
+ "comp": 0.0,
+ "limit_db": round(max(0.0, tp - CEILING_DBTP), 1),
+ "why": {"key": "suggest.why.master.gentle", "vars": nums},
+ }
+ target = math.floor(allowed * 2.0) / 2.0
+ return {
+ "preset": "custom",
+ "target_lufs": target,
+ "ceiling_dbtp": CEILING_DBTP,
+ "comp": 0.0,
+ "limit_db": limiting(target),
+ "why": {
+ "key": "suggest.why.master.dynamic",
+ "vars": {**nums, "target": target, "want": want, "limit": limiting(target)},
+ },
+ }
+
+
+def band_levels_after(power: np.ndarray, bands: list[Band], out_db: float = 0.0) -> np.ndarray:
+ return bands_db(apply_eq(power, bands, out_db))
diff --git a/app/pipeline/eq_suggest.py b/app/pipeline/eq_suggest.py
new file mode 100644
index 00000000..70066da0
--- /dev/null
+++ b/app/pipeline/eq_suggest.py
@@ -0,0 +1,171 @@
+"""A Suggest EQ answer, assembled from a measured spectrum, a target and the
+lane's current EQ. Pure: the API (app/api/suggest.py) measures, this decides
+and explains. Everything returned is numbers and i18n keys; the browser turns
+them into text and into an ordinary EQ descriptor.
+
+Modes:
+- "replace": the suggestion is fitted to the lane as it sounds with no EQ,
+ and replaces its EQ.
+- "merge" (graphical panels and the master): fitted to the lane as it sounds
+ now, through its current EQ; the new bands are added to the current ones
+ (up to the engine's 12) and the output gain is adjusted by what they
+ change.
+
+Loudness-neutral: the output gain cancels the estimated K-weighted loudness
+change of the new bands on the measured, gated spectrum (eq_fit.py), so
+Preview compares like with like. ``loudness`` reports what is left of it.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from app.pipeline.eq_analysis import NOMINAL_HZ, Spectrum, bands_db, loudness_of_power
+from app.pipeline.eq_fit import (
+ LAYOUTS,
+ apply_eq,
+ band_reasons,
+ fit_eq,
+ loudness_change_db,
+ neutral_out_db,
+ outside_rms_db,
+ weighted_rms_db,
+ weights_for,
+)
+from app.pipeline.eq_targets import Target
+from app.pipeline.fx_filters import MAX_BANDS, OUT_MAX, OUT_MIN, Eq
+
+# A lane must play this long (loud frames) and this loud to be measured.
+MIN_GATED_SECONDS = 3.0
+MIN_LANE_LUFS = -50.0
+
+
+def _round_list(xs, nd: int = 2) -> list[float]:
+ return [round(float(x), nd) for x in xs]
+
+
+def quiet_answer(spec: Spectrum, target: Target, choices: list[dict]) -> dict:
+ return {
+ "status": "quiet",
+ "target": target.id,
+ "choices": choices,
+ "analysis": {
+ "seconds": round(spec.seconds, 1),
+ "gated_seconds": round(spec.gated_seconds, 1),
+ },
+ "why": [{"key": "suggest.why.quiet", "vars": {}}],
+ }
+
+
+def is_quiet(spec: Spectrum) -> bool:
+ return (
+ spec.silent
+ or spec.gated_seconds < MIN_GATED_SECONDS
+ or loudness_of_power(spec.power) < MIN_LANE_LUFS
+ )
+
+
+def make_suggestion(
+ spec: Spectrum,
+ target: Target,
+ layout: str,
+ mode: str,
+ current: Eq | None,
+ max_bands: int,
+ choices: list[dict],
+) -> dict:
+ if layout not in LAYOUTS:
+ raise ValueError("unknown layout")
+ if is_quiet(spec):
+ return quiet_answer(spec, target, choices)
+ power0 = spec.power
+ cur_bands = list(current.bands) if current else []
+ cur_out = current.out if current else 0.0
+ power_cur = apply_eq(power0, cur_bands, cur_out)
+ merge = mode == "merge"
+ base = power_cur if merge else power0
+ measured = bands_db(base)
+ room = MAX_BANDS - len(cur_bands) if merge else max_bands
+ # Where a boost may go is decided on the lane as it is, before any EQ.
+ fit = fit_eq(
+ measured, target.bands_db, layout, max(0, min(max_bands, room)), boost_ref=bands_db(power0)
+ )
+ new_bands = [fb.band for fb in fit.bands]
+
+ if merge:
+ out = neutral_out_db(base, new_bands, cur_out)
+ final_bands = cur_bands + new_bands
+ else:
+ out = neutral_out_db(power0, new_bands, 0.0)
+ final_bands = new_bands
+ out = round(max(OUT_MIN, min(OUT_MAX, out)), 1)
+
+ raw_db = bands_db(power0)
+ cur_db = bands_db(power_cur)
+ after_db = bands_db(apply_eq(power0, final_bands, out))
+ tgt = np.asarray(target.bands_db, dtype=np.float64)
+ w = weights_for(raw_db)
+ # The target drawn level with the measured lane (only shapes compare).
+ offset = float(w @ (raw_db - tgt)) / float(w.sum()) if w.sum() > 0 else 0.0
+
+ def dist(levels: np.ndarray) -> dict:
+ return {
+ "rms": round(weighted_rms_db(tgt - levels, w), 2),
+ "outside": round(outside_rms_db(tgt - levels, w), 2),
+ }
+
+ new_change = loudness_change_db(base, new_bands, 0.0)
+ why = band_reasons(fit)
+ if new_bands:
+ why.append(
+ {
+ "key": "suggest.why.out",
+ "vars": {
+ "d": round(new_change, 1),
+ "out": round(out - (cur_out if merge else 0), 1),
+ },
+ }
+ )
+ else:
+ why.append({"key": "suggest.why.close", "vars": {}})
+ before = dist(cur_db if merge else raw_db)
+ after = dist(after_db)
+ why.append(
+ {"key": "suggest.why.distance", "vars": {"before": before["rms"], "after": after["rms"]}}
+ )
+
+ return {
+ "status": "ok",
+ "target": target.id,
+ "mode": mode,
+ "layout": layout,
+ "choices": choices,
+ "freqs": list(NOMINAL_HZ),
+ "levels": {
+ "raw": _round_list(raw_db),
+ "current": _round_list(cur_db),
+ "after": _round_list(after_db),
+ "target": _round_list(tgt + offset),
+ },
+ "distance": {"raw": dist(raw_db), "current": dist(cur_db), "suggested": after},
+ "loudness": {
+ # Estimated K-weighted loudness change against the lane with no EQ.
+ "current_change": round(loudness_change_db(power0, cur_bands, cur_out), 2),
+ "suggested_change": round(loudness_change_db(power0, final_bands, out), 2),
+ "bands_change": round(new_change, 2),
+ },
+ "bands": [fb.to_json() for fb in fit.bands],
+ "out": out,
+ "eq": {
+ "out": out,
+ "bands": [
+ {"type": b.type, "f": b.f, "g": b.g, "q": b.q, "slope": b.slope}
+ for b in final_bands
+ ],
+ },
+ "why": why,
+ "analysis": {
+ "seconds": round(spec.seconds, 1),
+ "gated_seconds": round(spec.gated_seconds, 1),
+ },
+ }
diff --git a/app/pipeline/eq_targets.json b/app/pipeline/eq_targets.json
new file mode 100644
index 00000000..778b244c
--- /dev/null
+++ b/app/pipeline/eq_targets.json
@@ -0,0 +1,40 @@
+{
+ "v": 1,
+ "about": "Target curves for Suggest EQ: third-octave band levels (dB, relative; only the shape matters) at the 31 ISO 266 bands 20 Hz to 20 kHz. Pink noise reads flat on this scale. A target is a taste choice, not a measurement of quality. Sources and derivation: app/pipeline/eq_targets.py.",
+ "bands_hz": [20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000],
+ "bases": {
+ "mix": [-22.0, -16.0, -10.5, -6.0, -3.0, -1.0, 0.5, 1.2, 1.8, 2.2, 2.5, 2.7, 2.7, 2.5, 2.3, 1.8, 1.2, 0.5, -0.4, -1.4, -2.5, -3.8, -5.3, -6.9, -8.7, -10.6, -12.6, -14.8, -17.1, -19.6, -22.2],
+ "bass": [-24.0, -18.0, -12.0, -6.0, -3.0, -1.0, 0.0, 0.0, 0.0, -0.5, -2.0, -4.0, -6.0, -8.0, -10.0, -12.5, -15.0, -17.5, -20.0, -22.0, -24.0, -26.5, -29.0, -32.0, -35.0, -38.0, -41.0, -44.0, -47.0, -50.0, -53.0],
+ "drums": [-24.0, -17.0, -11.0, -6.0, -2.5, -0.5, 0.0, 0.0, 0.0, -1.0, -2.0, -4.5, -7.0, -9.0, -10.5, -11.0, -11.5, -11.5, -11.5, -11.5, -11.5, -11.5, -11.5, -11.5, -12.0, -12.5, -13.5, -15.0, -17.0, -20.0, -25.0],
+ "guitar": [-36.0, -33.0, -30.0, -27.0, -24.0, -20.0, -15.0, -10.0, -6.0, -4.0, -3.0, -2.0, -1.5, -1.0, -0.5, 0.0, 0.0, 0.0, 0.0, -0.5, -1.0, -2.0, -3.5, -5.5, -8.0, -11.0, -14.0, -18.0, -22.0, -27.0, -33.0],
+ "keys": [-36.0, -32.0, -28.0, -23.0, -18.0, -13.0, -8.0, -4.0, -2.0, -1.0, -0.5, 0.0, 0.0, 0.0, 0.0, -0.5, -1.5, -2.5, -4.0, -5.5, -7.0, -9.0, -11.0, -13.0, -15.0, -17.5, -20.0, -23.0, -26.0, -30.0, -35.0],
+ "other": [-40.0, -34.0, -28.0, -22.0, -16.0, -10.0, -5.0, -1.5, 1.8, 2.2, 2.5, 2.7, 2.7, 2.5, 2.3, 1.8, 1.2, 0.5, -0.4, -1.4, -2.5, -3.8, -5.3, -6.9, -8.7, -10.6, -12.6, -14.8, -17.1, -19.6, -22.2]
+ },
+ "variants": {
+ "balanced": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ "warm": [0, 0, 0, 0, 0, 0.3, 0.5, 0.8, 1.0, 1.2, 1.2, 1.0, 0.7, 0.3, 0, 0, 0, 0, -0.2, -0.4, -0.7, -1.0, -1.4, -1.8, -2.2, -2.5, -2.8, -3.0, -3.0, -3.0, -3.0],
+ "bright": [0, 0, 0, 0, 0.3, 0.5, 0.5, 0.3, 0, -0.3, -0.6, -0.8, -0.8, -0.6, -0.3, 0, 0, 0, 0.2, 0.4, 0.7, 1.0, 1.4, 1.8, 2.2, 2.5, 2.8, 3.0, 3.0, 3.0, 3.0],
+ "bassy": [3.0, 3.0, 3.0, 3.0, 3.0, 2.8, 2.4, 1.8, 1.2, 0.6, 0.2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
+ },
+ "targets": [
+ {"id": "mix.balanced", "use": "mix", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": -14},
+ {"id": "mix.warm", "use": "mix", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": -14},
+ {"id": "mix.bright", "use": "mix", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": -14},
+ {"id": "mix.bassy", "use": "mix", "variant": "bassy", "label_key": "suggest.target.bassy", "target_lufs": -14},
+ {"id": "bass.balanced", "use": "bass", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": null},
+ {"id": "bass.warm", "use": "bass", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": null},
+ {"id": "bass.bright", "use": "bass", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": null},
+ {"id": "drums.balanced", "use": "drums", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": null},
+ {"id": "drums.warm", "use": "drums", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": null},
+ {"id": "drums.bright", "use": "drums", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": null},
+ {"id": "guitar.balanced", "use": "guitar", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": null},
+ {"id": "guitar.warm", "use": "guitar", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": null},
+ {"id": "guitar.bright", "use": "guitar", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": null},
+ {"id": "keys.balanced", "use": "keys", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": null},
+ {"id": "keys.warm", "use": "keys", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": null},
+ {"id": "keys.bright", "use": "keys", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": null},
+ {"id": "other.balanced", "use": "other", "variant": "balanced", "label_key": "suggest.target.balanced", "target_lufs": null},
+ {"id": "other.warm", "use": "other", "variant": "warm", "label_key": "suggest.target.warm", "target_lufs": null},
+ {"id": "other.bright", "use": "other", "variant": "bright", "label_key": "suggest.target.bright", "target_lufs": null}
+ ]
+}
diff --git a/app/pipeline/eq_targets.py b/app/pipeline/eq_targets.py
new file mode 100644
index 00000000..c096f566
--- /dev/null
+++ b/app/pipeline/eq_targets.py
@@ -0,0 +1,143 @@
+"""Target curves for Suggest EQ (the data is eq_targets.json).
+
+A target is a third-octave spectrum shape: 31 levels in dB at the ISO 266
+bands from 20 Hz to 20 kHz, on a scale where pink noise is flat. Only the
+shape matters; the fitter (eq_fit.py) removes the overall level. Shape of one
+target, as agreed with the mastering report (01-mastering.md 5.5, 09 4.3):
+``{id, label_key, bands_db[31], target_lufs}``.
+
+**A target is a taste choice, not a fact.** It says "this is a common
+balance", not "this is correct". The UI says so next to every suggestion.
+
+Where the numbers come from (sources in docs/plans/0.21.0/overnight-log.md,
+task G):
+
+- **mix, Balanced** (the master and the "original" lane): from 100 Hz up,
+ the mean long-term average spectrum of 12,345 popular music tracks fitted
+ by Elowsson and Friberg ("Long-term average spectrum in popular music and
+ its relation to the level of the percussion", AES 142nd Convention, 2017,
+ paper 9762, equation 6), converted from power density to third-octave
+ levels (+10 log10 f, i.e. +3 dB per octave). In third octaves it is close
+ to pink up to about 1 kHz (+0.7 dB/oct at 200 Hz, -2 dB/oct at 800 Hz)
+ and falls faster above (-4.6 dB/oct at 3.2 kHz, -5.9 at 6.4 kHz), in line
+ with the "about 5 dB per octave from 100 to 4000 Hz" in power density that
+ Pestana, Ma, Reiss, Barbosa and Black found on 772 number-one singles
+ ("Spectral characteristics of popular commercial recordings 1950-2010",
+ AES 135th Convention, 2013). So it is *not* pink noise: music is darker
+ than pink above 1 kHz, and a pink target would brighten every mix.
+ Below 100 Hz the paper's mean falls steeply (its set leans on folk:
+ Dylan, Neil Young), and its authors note that deviations below the mean in
+ the bass are "generally less alarming". Bass levels in popular music rose
+ over the decades (Hove, Vuust and Stupacher, JASA 145(4), 2019), and
+ Pestana found an extended low end in hip-hop and electronica. So below
+ 100 Hz the curve is set by hand to a gentler roll-off (flat to 63 Hz,
+ -6 dB at 40, -22 at 20). Above 15.7 kHz (the paper's analysis limit) the
+ fit is extrapolated. Checked against the three songs available locally
+ (Happy, Shining Star, Come As You Are): their 1 kHz to 10 kHz balance lies
+ on both sides of it.
+- **Warm / Bright**: the variants tonal-balance practice offers (iZotope's
+ Tonal Balance Control analyses thousands of commercial masters into style
+ targets): Warm is up to 3 dB less above 4 kHz and 1 dB more at 150-300
+ Hz (Pestana: folk and jazz decay at the top); Bright is the opposite.
+ Elowsson and Friberg found that tracks with more percussion have relatively
+ more bass and more highs. **Full low end** (mix only) adds up to 3 dB
+ below 120 Hz (Pestana: hip-hop and electronica).
+- **Instruments** (bass, drums, guitar, keys, other): set by hand from common
+ mixing practice for where each instrument sits (bass: body 60-160 Hz,
+ falling about 7 dB per octave above 250 Hz; drums: kick 50-125 Hz, a
+ box-and-mud dip at 300-800 Hz, flat attack and cymbals; guitar: no
+ rumble under 100 Hz, flat 300 Hz to 2 kHz, the speaker roll-off above
+ 5 kHz; keys: 125 Hz to 1 kHz body, falling above; other: the mix curve
+ without its lowest octave), then compared with the separated stems of the
+ three local songs. No published per-instrument average was found; these
+ are the weakest of the targets and say so in the log.
+- **target_lufs**: the integrated loudness a master target is meant for
+ (-14, the level the large streaming services normalise to); null for
+ instruments.
+- Vocals have no target: the vocal lanes have a placeholder panel, not an EQ.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+
+from app.pipeline.eq_analysis import N_BANDS, NOMINAL_HZ
+
+_PATH = Path(__file__).with_name("eq_targets.json")
+
+# Which target family a mixer lane uses by default. Recording tracks have no
+# fixed instrument: they get "other" and may pick any instrument's targets.
+LANE_USE = {
+ "bass": "bass",
+ "drums": "drums",
+ "guitar": "guitar",
+ "piano": "keys",
+ "other": "other",
+ "original": "mix",
+}
+TRACK_USES = ("other", "guitar", "bass", "keys", "drums")
+
+
+@dataclass(frozen=True)
+class Target:
+ id: str
+ use: str
+ variant: str
+ label_key: str
+ bands_db: tuple[float, ...]
+ target_lufs: float | None
+
+ def to_json(self) -> dict:
+ return {
+ "id": self.id,
+ "label_key": self.label_key,
+ "bands_db": list(self.bands_db),
+ "target_lufs": self.target_lufs,
+ }
+
+
+@lru_cache(maxsize=1)
+def load_targets() -> dict[str, Target]:
+ data = json.loads(_PATH.read_text(encoding="utf-8"))
+ if tuple(data["bands_hz"]) != tuple(NOMINAL_HZ):
+ raise ValueError("eq_targets.json: band frequencies differ from the analysis")
+ out: dict[str, Target] = {}
+ for t in data["targets"]:
+ base = data["bases"][t["use"]]
+ offset = data["variants"][t["variant"]]
+ if len(base) != N_BANDS or len(offset) != N_BANDS:
+ raise ValueError(f"eq_targets.json: {t['id']} does not have {N_BANDS} bands")
+ bands = tuple(round(float(b) + float(o), 2) for b, o in zip(base, offset, strict=True))
+ out[t["id"]] = Target(
+ id=t["id"],
+ use=t["use"],
+ variant=t["variant"],
+ label_key=t["label_key"],
+ bands_db=bands,
+ target_lufs=t["target_lufs"],
+ )
+ return out
+
+
+def get_target(target_id: str) -> Target | None:
+ return load_targets().get(target_id)
+
+
+def uses_for_lane(lane: str, recorded: bool) -> tuple[str, ...]:
+ """The target families offered for a lane, its own first."""
+ if recorded:
+ return TRACK_USES
+ use = LANE_USE.get(lane)
+ return (use,) if use else ()
+
+
+def targets_for_uses(uses: tuple[str, ...]) -> list[Target]:
+ return [t for u in uses for t in load_targets().values() if t.use == u]
+
+
+def default_target(lane: str, recorded: bool) -> Target | None:
+ uses = uses_for_lane(lane, recorded)
+ return get_target(f"{uses[0]}.balanced") if uses else None
diff --git a/app/pipeline/errors.py b/app/pipeline/errors.py
index 7a617e9d..c14c800e 100644
--- a/app/pipeline/errors.py
+++ b/app/pipeline/errors.py
@@ -42,6 +42,9 @@ def __init__(
"cannot allocate memory",
"out of memory",
"memoryerror",
+ # MIOpen, ROCm's cuDNN, failing to allocate on an AMD GPU (#825).
+ # Here, ahead of the other MIOpen statuses below.
+ "miopenstatusallocfailed",
),
),
(
@@ -53,6 +56,13 @@ def __init__(
"cudnn error",
"not currently implemented for the mps device",
"torch not compiled with cuda",
+ # A ROCm build of torch on an AMD GPU (#825): no kernels for the
+ # card's gfx target (HIP's "invalid device function" is caught
+ # above), a HIP runtime that cannot reach the card (no /dev/kfd
+ # access), and any other MIOpen failure.
+ "hiperrornobinaryforgpu",
+ "no hip gpus are available",
+ "miopenstatus",
),
),
(
@@ -63,6 +73,10 @@ def __init__(
"disk quota exceeded",
),
),
+ # The separation model's download (#125), in the words of
+ # app/core/model_download.py and separate.py's stall message. Only those
+ # words: a network error on its own is just as likely a song's download.
+ ("model-download", ("model download failed:",)),
# Source-fetch failures (#434). Placed after the resource-level causes above
# so a genuine disk-full or OOM during a download still classifies as such,
# and before "bad-input" so yt-dlp's wording wins over the generic
@@ -105,7 +119,8 @@ def __init__(
def classify_failure(text: str) -> str:
"""Map failure output to one of: out-of-memory, unsupported-device,
- disk-full, source-blocked, source-unavailable, bad-input, unknown."""
+ disk-full, model-download, source-blocked, source-unavailable, bad-input,
+ unknown."""
low = text.lower()
for cause, patterns in _CAUSE_PATTERNS:
if any(p in low for p in patterns):
diff --git a/app/pipeline/fx_filters.py b/app/pipeline/fx_filters.py
new file mode 100644
index 00000000..e46df411
--- /dev/null
+++ b/app/pipeline/fx_filters.py
@@ -0,0 +1,338 @@
+"""Export side of the EQ: the same biquad coefficients as the live engine.
+
+The studio EQ runs in the browser as native BiquadFilterNodes
+(static/js/fxChain.js). Their coefficients are fixed by the Web Audio spec's
+"Filters Characteristics" (the RBJ Audio EQ Cookbook, with Q in dB for
+lowpass/highpass and the shelf slope fixed at S = 1). This module computes
+exactly those coefficients and hands them to ffmpeg's ``biquad`` filter, so an
+export runs the filter the user heard. Every function mirrors one in
+static/js/eqMath.js; tests/test_fx_filters.py holds them to each other.
+
+Pure functions, no I/O. The ``fx=`` export parameter is parsed and validated
+here (``parse_fx_param``): base64url JSON, size-capped, every field typed and
+range-checked, and nothing but numbers this module formats itself ever reaches
+the ffmpeg filter graph. Contract: docs/plans/0.21.0/32-eq-engine-contract.md.
+"""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+import math
+import re
+from dataclasses import dataclass, field
+
+EQ_ID = "eq"
+FX_WIRE_VERSION = 1
+
+EQ_TYPES = ("bell", "lowshelf", "highshelf", "lowcut", "highcut", "notch", "bandpass", "tilt")
+EQ_SLOPES = (12, 24, 36, 48)
+FREQ_MIN, FREQ_MAX = 10.0, 20000.0
+GAIN_MIN, GAIN_MAX = -30.0, 30.0
+Q_MIN, Q_MAX = 0.025, 40.0
+OUT_MIN, OUT_MAX = -24.0, 24.0
+MAX_BANDS = 12
+# Bands carry Q to 0.001, so a cut's "no resonance" reference is 0.707: q =
+# 0.707 gives exactly the Butterworth section Qs (mirrors EQ_DEFAULT_Q).
+DEFAULT_Q = 0.707
+
+# The raw parameter, before decoding. 12 bands on each of a dozen lanes fit
+# in under 6 KB; the cap keeps the whole URL well inside the server's 16 KB
+# request-line limit.
+FX_PARAM_MAX_CHARS = 8192
+MAX_FX_LANES = 32
+MAX_EFFECTS_PER_SLOT = 4
+# Biquad sections in one ffmpeg command. Each is about 150 characters of
+# filter graph, and Windows caps a whole command line at 32767 characters.
+MAX_TOTAL_SECTIONS = 160
+
+_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]*$")
+_LANE_KEY_RE = re.compile(r"^[a-z0-9_]{1,32}$")
+
+
+class FxError(ValueError):
+ """A malformed or out-of-range ``fx`` value. The message is safe to show."""
+
+
+@dataclass(frozen=True)
+class Band:
+ type: str
+ f: float
+ g: float
+ q: float
+ slope: int
+
+
+@dataclass(frozen=True)
+class Eq:
+ out: float
+ bands: tuple[Band, ...]
+
+
+@dataclass(frozen=True)
+class Section:
+ """One Web Audio BiquadFilterNode: its type, frequency, LINEAR Q, gain."""
+
+ type: str
+ f: float
+ q: float
+ g: float
+
+
+@dataclass
+class FxRequest:
+ lanes: dict[str, list[Eq]] = field(default_factory=dict)
+ master: list[Eq] = field(default_factory=list)
+
+
+# ---------------------------------------------------------------------------
+# Maths (mirrors static/js/eqMath.js)
+# ---------------------------------------------------------------------------
+
+
+def band_is_neutral(band: Band) -> bool:
+ if band.type in ("bell", "lowshelf", "highshelf", "tilt"):
+ return band.g == 0
+ return False
+
+
+def butterworth_qs(order: int) -> list[float]:
+ """Section Qs of an even-order Butterworth filter, lowest first."""
+ return sorted(
+ 1.0 / (2.0 * math.cos((2 * k - 1) * math.pi / (2 * order)))
+ for k in range(1, order // 2 + 1)
+ )
+
+
+def band_sections(band: Band) -> list[Section]:
+ if band_is_neutral(band):
+ return []
+ t, f, g, q = band.type, band.f, band.g, band.q
+ if t == "bell":
+ return [Section("peaking", f, q, g)]
+ if t == "lowshelf":
+ return [Section("lowshelf", f, 0.0, g)]
+ if t == "highshelf":
+ return [Section("highshelf", f, 0.0, g)]
+ if t == "notch":
+ return [Section("notch", f, q, 0.0)]
+ if t == "bandpass":
+ return [Section("bandpass", f, q, 0.0)]
+ if t == "tilt":
+ return [Section("lowshelf", f, 0.0, -g / 2), Section("highshelf", f, 0.0, g / 2)]
+ if t in ("lowcut", "highcut"):
+ wa = "highpass" if t == "lowcut" else "lowpass"
+ qs = butterworth_qs(band.slope // 6)
+ qs[-1] *= q / DEFAULT_Q
+ return [Section(wa, f, sq, 0.0) for sq in qs]
+ return []
+
+
+def eq_sections(eq: Eq) -> list[Section]:
+ out: list[Section] = []
+ for band in eq.bands:
+ out.extend(band_sections(band))
+ return out
+
+
+def node_q(section: Section) -> float:
+ """BiquadFilterNode.Q for the section: dB for lowpass/highpass."""
+ if section.type in ("lowpass", "highpass"):
+ return 20.0 * math.log10(section.q)
+ if section.type in ("lowshelf", "highshelf"):
+ return 1.0
+ return section.q
+
+
+def section_coefficients(section: Section, fs: float) -> tuple[float, float, float, float, float]:
+ """(b0, b1, b2, a1, a2), normalised to a0 = 1, by the Web Audio spec."""
+ f0 = min(max(section.f, 0.0), fs / 2)
+ a = 10.0 ** (section.g / 40.0)
+ w0 = 2.0 * math.pi * f0 / fs
+ cw = math.cos(w0)
+ sw = math.sin(w0)
+ a_q = sw / (2.0 * section.q) if section.q else 0.0
+ a_qdb = sw / (2.0 * 10.0 ** (node_q(section) / 20.0))
+ a_s = (sw / 2.0) * math.sqrt(2.0) # S = 1
+ t = section.type
+ if t == "lowpass":
+ b0, b1, b2 = (1 - cw) / 2, 1 - cw, (1 - cw) / 2
+ a0, a1, a2 = 1 + a_qdb, -2 * cw, 1 - a_qdb
+ elif t == "highpass":
+ b0, b1, b2 = (1 + cw) / 2, -(1 + cw), (1 + cw) / 2
+ a0, a1, a2 = 1 + a_qdb, -2 * cw, 1 - a_qdb
+ elif t == "bandpass":
+ b0, b1, b2 = a_q, 0.0, -a_q
+ a0, a1, a2 = 1 + a_q, -2 * cw, 1 - a_q
+ elif t == "notch":
+ b0, b1, b2 = 1.0, -2 * cw, 1.0
+ a0, a1, a2 = 1 + a_q, -2 * cw, 1 - a_q
+ elif t == "peaking":
+ b0, b1, b2 = 1 + a_q * a, -2 * cw, 1 - a_q * a
+ a0, a1, a2 = 1 + a_q / a, -2 * cw, 1 - a_q / a
+ elif t == "lowshelf":
+ k = 2 * a_s * math.sqrt(a)
+ b0 = a * ((a + 1) - (a - 1) * cw + k)
+ b1 = 2 * a * ((a - 1) - (a + 1) * cw)
+ b2 = a * ((a + 1) - (a - 1) * cw - k)
+ a0 = (a + 1) + (a - 1) * cw + k
+ a1 = -2 * ((a - 1) + (a + 1) * cw)
+ a2 = (a + 1) + (a - 1) * cw - k
+ elif t == "highshelf":
+ k = 2 * a_s * math.sqrt(a)
+ b0 = a * ((a + 1) + (a - 1) * cw + k)
+ b1 = -2 * a * ((a - 1) + (a + 1) * cw)
+ b2 = a * ((a + 1) + (a - 1) * cw - k)
+ a0 = (a + 1) - (a - 1) * cw + k
+ a1 = 2 * ((a - 1) - (a + 1) * cw)
+ a2 = (a + 1) - (a - 1) * cw - k
+ else:
+ return (1.0, 0.0, 0.0, 0.0, 0.0)
+ return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
+
+
+def coefficients_mag_db(c: tuple[float, float, float, float, float], f: float, fs: float) -> float:
+ b0, b1, b2, a1, a2 = c
+ w = 2.0 * math.pi * f / fs
+ num = complex(
+ b0 + b1 * math.cos(w) + b2 * math.cos(2 * w), -(b1 * math.sin(w) + b2 * math.sin(2 * w))
+ )
+ den = complex(
+ 1 + a1 * math.cos(w) + a2 * math.cos(2 * w), -(a1 * math.sin(w) + a2 * math.sin(2 * w))
+ )
+ return 20.0 * math.log10(abs(num) / abs(den))
+
+
+def eq_response_db(eq: Eq, freqs: list[float], fs: float) -> list[float]:
+ """|H| in dB at each frequency, output gain included."""
+ coefs = [section_coefficients(s, fs) for s in eq_sections(eq)]
+ return [eq.out + sum(coefficients_mag_db(c, f, fs) for c in coefs) for f in freqs]
+
+
+# ---------------------------------------------------------------------------
+# ffmpeg filter strings
+# ---------------------------------------------------------------------------
+
+
+def _num(v: float) -> str:
+ # repr is the shortest string that reads back as the same double, so
+ # ffmpeg gets bit-for-bit the coefficients computed here.
+ return repr(float(v))
+
+
+def section_filter(section: Section, fs: float) -> str:
+ b0, b1, b2, a1, a2 = section_coefficients(section, fs)
+ # r=f64: filter in double precision. With the default ("auto") the filter
+ # would run in whatever format arrives, 16-bit for a 16-bit WAV.
+ return (
+ f"biquad=b0={_num(b0)}:b1={_num(b1)}:b2={_num(b2)}:a0=1.0:a1={_num(a1)}:a2={_num(a2)}:r=f64"
+ )
+
+
+def eq_filter(eq: Eq, fs: float) -> str:
+ """The filter chain for one EQ at sample rate ``fs``, "" when it changes
+ nothing (so a flat EQ leaves the graph exactly as it was)."""
+ parts = [section_filter(s, fs) for s in eq_sections(eq)]
+ if eq.out != 0:
+ parts.append(f"volume={_num(10.0 ** (eq.out / 20.0))}")
+ return ",".join(parts)
+
+
+def slot_filter(effects: list[Eq], fs: float) -> str:
+ """All effects of one slot, in order, "" when none changes the sound."""
+ return ",".join(p for p in (eq_filter(e, fs) for e in effects) if p)
+
+
+def slot_section_count(effects: list[Eq]) -> int:
+ return sum(len(eq_sections(e)) for e in effects)
+
+
+# ---------------------------------------------------------------------------
+# Parsing and validation of the fx= parameter
+# ---------------------------------------------------------------------------
+
+
+def _reject_constant(name: str) -> float:
+ raise FxError("fx: numbers must be finite")
+
+
+def _number(v: object, lo: float, hi: float, what: str) -> float:
+ # bool is an int in Python: refuse it explicitly.
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ raise FxError(f"fx: {what} must be a number")
+ x = float(v)
+ if not math.isfinite(x) or x < lo or x > hi:
+ raise FxError(f"fx: {what} out of range")
+ return x
+
+
+def _parse_band(raw: object) -> Band:
+ if not isinstance(raw, list) or len(raw) != 5:
+ raise FxError("fx: a band is [type, f, g, q, slope]")
+ t, f, g, q, slope = raw
+ if not isinstance(t, str) or t not in EQ_TYPES:
+ raise FxError("fx: unknown band type")
+ f = _number(f, FREQ_MIN, FREQ_MAX, "frequency")
+ g = _number(g, GAIN_MIN, GAIN_MAX, "gain")
+ q = _number(q, Q_MIN, Q_MAX, "Q")
+ if isinstance(slope, bool) or not isinstance(slope, int) or slope not in EQ_SLOPES:
+ raise FxError("fx: slope must be 12, 24, 36 or 48")
+ return Band(t, f, g, q, slope)
+
+
+def _parse_effect(raw: object) -> Eq:
+ if not isinstance(raw, dict):
+ raise FxError("fx: an effect must be an object")
+ if set(raw) - {"id", "out", "b"}:
+ raise FxError("fx: unknown effect field")
+ if raw.get("id") != EQ_ID:
+ raise FxError("fx: unknown effect")
+ out = _number(raw.get("out", 0), OUT_MIN, OUT_MAX, "output gain")
+ bands = raw.get("b", [])
+ if not isinstance(bands, list) or len(bands) > MAX_BANDS:
+ raise FxError("fx: too many bands")
+ return Eq(out, tuple(_parse_band(b) for b in bands))
+
+
+def _parse_slot(raw: object) -> list[Eq]:
+ if not isinstance(raw, list) or len(raw) > MAX_EFFECTS_PER_SLOT:
+ raise FxError("fx: a slot is a list of at most 4 effects")
+ effects = [_parse_effect(e) for e in raw]
+ if len(effects) > 1:
+ raise FxError("fx: one EQ per slot")
+ return effects
+
+
+def parse_fx_param(raw: str | None) -> FxRequest:
+ """Decode and validate ``fx=``. Empty means no effects. Raises FxError."""
+ if not raw:
+ return FxRequest()
+ if len(raw) > FX_PARAM_MAX_CHARS or not _B64URL_RE.match(raw):
+ raise FxError("fx: malformed")
+ try:
+ text = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)).decode("utf-8")
+ body = json.loads(text, parse_constant=_reject_constant)
+ except FxError:
+ raise
+ except (binascii.Error, UnicodeDecodeError, ValueError, RecursionError):
+ raise FxError("fx: malformed") from None
+ if not isinstance(body, dict) or set(body) - {"v", "lanes", "master"}:
+ raise FxError("fx: malformed")
+ if body.get("v") != FX_WIRE_VERSION or isinstance(body.get("v"), bool):
+ raise FxError("fx: unsupported version")
+ lanes_raw = body.get("lanes", {})
+ if not isinstance(lanes_raw, dict) or len(lanes_raw) > MAX_FX_LANES:
+ raise FxError("fx: malformed lanes")
+ lanes: dict[str, list[Eq]] = {}
+ for name, slot in lanes_raw.items():
+ if not _LANE_KEY_RE.match(name):
+ raise FxError("fx: unknown lane")
+ lanes[name] = _parse_slot(slot)
+ master = _parse_slot(body.get("master", []))
+ return FxRequest(lanes=lanes, master=master)
+
+
+def check_section_budget(sections: int) -> None:
+ if sections > MAX_TOTAL_SECTIONS:
+ raise FxError("fx: too many EQ bands for one export")
diff --git a/app/pipeline/idle_unload.py b/app/pipeline/idle_unload.py
new file mode 100644
index 00000000..abe94000
--- /dev/null
+++ b/app/pipeline/idle_unload.py
@@ -0,0 +1,177 @@
+"""Give back the memory the separation models hold while StemDeck is idle (#975).
+
+The persistent Demucs worker (separate.py, #309) and the beat_this model
+(beat_detect.py) load on first use and used to stay loaded until the server
+stopped. Measured on an RTX 3080, 2026-11-05: the idle CUDA worker held
+1.93 GB of RAM and 1.11 GB of VRAM, the idle CPU worker 1.46 GB of RAM.
+
+Each is an IdleResource. A pipeline step holds `in_use()` around its use, and
+the timer here unloads a resource nobody has used for the configured minutes
+(the Settings value `idle_unload_minutes`, 0 for never). The next use loads it
+again the ordinary way, `_get_worker()` and `_get_model()`, so the only cost is
+the warm start the worker exists for: 5.8 s on the first separation after an
+unload, measured on the same machine.
+
+What keeps an unload from landing under a job:
+
+- `in_use()` and the unload take the same lock, and the unload runs only with
+ no user. A job that arrives during an unload waits for it to finish, then
+ loads afresh, so it is never handed a worker that is half torn down.
+- The timer does not even look while a job runs or waits in the queue, the
+ pipeline lock is held (fill-stems, vocal split), or the server is stopping.
+ The lock above is what makes it safe; this is what keeps it from unloading a
+ worker that a job queued a second ago is about to use.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+import time
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+
+from app.core.config import IDLE_UNLOAD_CHECK_SEC
+from app.core.settings import get_idle_unload_minutes
+from app.pipeline import jobqueue
+
+logger = logging.getLogger("stemdeck.idle")
+
+
+class IdleResource:
+ """Something loaded on first use, worth unloading after a quiet spell.
+
+ `is_loaded` and `unload` are the owner's own functions; `unload` must be
+ safe to call when nothing is loaded. `after_unload`, if given, runs once
+ the lock is released, for clean-up no use should wait on (a full garbage
+ collection). `clock` is a seam for tests."""
+
+ def __init__(
+ self,
+ name: str,
+ is_loaded: Callable[[], bool],
+ unload: Callable[[], None],
+ clock: Callable[[], float] = time.monotonic,
+ after_unload: Callable[[], None] | None = None,
+ ) -> None:
+ self.name = name
+ self._is_loaded = is_loaded
+ self._unload = unload
+ self._after_unload = after_unload
+ self._clock = clock
+ self._lock = threading.Lock()
+ self._users = 0
+ self._last_used = clock()
+
+ @contextmanager
+ def in_use(self) -> Iterator[None]:
+ """Hold around any use. Waits for an unload in progress to finish."""
+ with self._lock:
+ self._users += 1
+ try:
+ yield
+ finally:
+ with self._lock:
+ self._users -= 1
+ # From the end of the use: a forty-minute CPU separation must
+ # not count as forty idle minutes the moment it finishes.
+ self._last_used = self._clock()
+
+ def touch(self) -> None:
+ """Count now as a use: the idle time starts again from here.
+
+ Called on the event loop while the pipeline is busy, so it takes no
+ lock: the lock can be held for seconds by an unload, and the loop must
+ not wait on it. A plain float store; whichever of this and in_use()
+ writes last, both wrote "now"."""
+ self._last_used = self._clock()
+
+ def unload_if_idle(self, idle_sec: float) -> bool:
+ """Unload when loaded, unused, and idle for `idle_sec`. True if it did.
+
+ The lock is held across the unload (up to 15 s for a worker that will
+ not stop: separate._kill_worker), which is what makes a use that arrives meanwhile
+ wait for it rather than race it."""
+ with self._lock:
+ if self._users or not self._is_loaded():
+ return False
+ idle = self._clock() - self._last_used
+ if idle < idle_sec:
+ return False
+ self._unload()
+ logger.info("unloaded %s after %d idle minutes", self.name, round(idle / 60))
+ if self._after_unload is not None:
+ self._after_unload()
+ return True
+
+ def unload_now(self) -> None:
+ """Unload whatever the state, for shutdown. Waits for an idle unload
+ in progress, so the two never stop the same process at once."""
+ with self._lock:
+ self._unload()
+
+
+_resources: list[IdleResource] = []
+
+
+def register(resource: IdleResource) -> IdleResource:
+ _resources.append(resource)
+ return resource
+
+
+def _pipeline_busy() -> bool:
+ """A job running, a job waiting in a queue that is not paused, anything
+ holding the pipeline lock, or the server stopping. Read on the event loop,
+ as the queue and the lock are.
+
+ A paused queue is idle here: restored jobs can wait for Start for days,
+ and nothing runs until someone presses it. A job that starts meanwhile is
+ still safe: its in_use() waits for an unload to finish."""
+ from app.pipeline.runner import _pipeline_lock
+
+ running, waiting = jobqueue.snapshot()
+ queued = bool(waiting) and not jobqueue.is_paused()
+ return running is not None or queued or _pipeline_lock.locked() or jobqueue.is_stopping()
+
+
+def _unload_idle(idle_sec: float) -> list[str]:
+ done = []
+ for resource in _resources:
+ try:
+ if resource.unload_if_idle(idle_sec):
+ done.append(resource.name)
+ except Exception:
+ # One resource failing to stop must not keep the other loaded,
+ # nor end the timer.
+ logger.exception("could not unload %s", resource.name)
+ return done
+
+
+async def check() -> list[str]:
+ """One look: unload what has been idle long enough. Returns the names."""
+ minutes = get_idle_unload_minutes()
+ if _pipeline_busy():
+ # The idle time counts from when the pipeline goes quiet, not from the
+ # end of the separation: lyrics and sections can run for many minutes
+ # after it, and an unload the moment they end would hand the next
+ # import a cold start for nothing. The idle time restarts at the last
+ # busy check, so an unload can come up to IDLE_UNLOAD_CHECK_SEC (30 s)
+ # earlier than the minutes after the pipeline goes quiet.
+ for resource in _resources:
+ resource.touch()
+ return []
+ if minutes <= 0:
+ return []
+ # Off the loop: stopping the worker waits up to 15 s for it to exit.
+ return await asyncio.to_thread(_unload_idle, minutes * 60)
+
+
+async def run(interval_sec: float = IDLE_UNLOAD_CHECK_SEC) -> None:
+ """The timer. Started by the app lifespan, cancelled at shutdown."""
+ while True:
+ await asyncio.sleep(interval_sec)
+ try:
+ await check()
+ except Exception:
+ logger.exception("idle unload check failed")
diff --git a/app/pipeline/jobqueue.py b/app/pipeline/jobqueue.py
index 9e61dd91..6b658bbc 100644
--- a/app/pipeline/jobqueue.py
+++ b/app/pipeline/jobqueue.py
@@ -27,8 +27,12 @@
from app.core.config import JOBS_DIR
from app.core.models import FINISHED_STATUSES, Job, _set, settle_cancelled
+from app.core.registry import deleted_ids as registry_deleted_ids
from app.core.registry import get as registry_get
from app.core.registry import persist as registry_persist
+from app.core.registry import remove as registry_remove
+from app.pipeline.collect import remove_job_dir
+from app.pipeline.identify import release_source
logger = logging.getLogger("stemdeck.queue")
@@ -193,14 +197,14 @@ async def _dispatch(job: Job) -> None:
stage="Error: Source file missing",
error="The uploaded file is no longer available. Re-import it to finish.",
)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
return
await run_local_pipeline(job, source, JOBS_DIR)
return
await run_pipeline(job, source_url, JOBS_DIR)
-def _finalise_dropped_job(job: Job) -> None:
+async def _finalise_dropped_job(job: Job) -> None:
"""Complete a cancellation the API could not finish itself.
cancel_job can only finalise a job it still finds in the queue. Between
@@ -211,8 +215,27 @@ def _finalise_dropped_job(job: Job) -> None:
if not job.cancel_requested or job.status in FINISHED_STATUSES:
return
if settle_cancelled(job):
- cleanup_job_dir(job.id)
- registry_persist(JOBS_DIR)
+ await asyncio.to_thread(cleanup_job_dir, job.id)
+ await asyncio.to_thread(registry_persist, JOBS_DIR)
+ await finish_deferred_delete(job, JOBS_DIR)
+
+
+async def finish_deferred_delete(job: Job, jobs_dir: Path) -> None:
+ """Remove a job emptied from the Trash while its stop was settling, the
+ delete DELETE answered 202 for (#748).
+
+ Called after every final status write. DELETE marks the id deleted and
+ then reads the status, so whichever of the two comes second sees the
+ other: a job DELETE found still running is removed here, one it found
+ settled it removes itself, and both doing it is harmless."""
+ if job.id not in registry_deleted_ids():
+ return
+ # A fingerprint may still have the source open (Windows).
+ release_source(job.id)
+ if not await asyncio.to_thread(remove_job_dir, jobs_dir / job.id):
+ logger.warning("[%s] deleted job's folder could not be removed", job.id)
+ registry_remove(job.id)
+ await asyncio.to_thread(registry_persist, jobs_dir)
async def _worker_loop() -> None:
@@ -247,7 +270,7 @@ async def _worker_loop() -> None:
# pending_count against the capacity limit, its uploaded source
# never freed, and -- because "queued" is persisted -- re-queued
# on every restart (#520).
- _finalise_dropped_job(job)
+ await _finalise_dropped_job(job)
continue
# Claim it. No await between the pop and this status write, so a job is
@@ -255,7 +278,12 @@ async def _worker_loop() -> None:
# register_if_capacity keep counting only "queued" as the queue depth.
_set_running(job_id)
_set(job, status="processing", stage="Starting...", progress=0.0)
+ # The count registry.restore caps, on disk before the run: a job that
+ # takes the process down never reaches a save of its own. Saved here,
+ # not in a thread, so no await opens between the claim and the run.
+ job.resume_attempts += 1
try:
+ registry_persist(JOBS_DIR)
await _dispatch(job)
except Exception:
# A crash in one job must not take the queue down with it.
@@ -295,14 +323,27 @@ def is_paused() -> bool:
def request_stop() -> None:
- """Stop picking up new work. Deliberately does not cancel the in-flight job:
- it is blocked in asyncio.to_thread, which cannot be cancelled, so it dies
- with the process exactly as it did before the queue existed."""
+ """Stop picking up new work, at shutdown. Deliberately does not cancel the
+ in-flight job: it is blocked in asyncio.to_thread, which cannot be
+ cancelled. The shutdown then kills the demucs worker under it, and the
+ runner reads that as an interruption, not a failure (is_stopping).
+
+ A clean stop is not the crash registry.restore's one resume is kept for,
+ so the running job gets its resume back, on disk before the kill."""
global _stopping
_stopping = True
+ running = running_id()
+ job = registry_get(running) if running else None
+ if job is not None:
+ job.resume_attempts = 0
+ registry_persist(JOBS_DIR)
_notify()
+def is_stopping() -> bool:
+ return _stopping
+
+
def cleanup_job_dir(job_id: str) -> None:
"""Remove a cancelled-before-start job's directory. A queued local upload
holds its source file (up to 400 MB) for the whole wait."""
diff --git a/app/pipeline/lyrics_align.py b/app/pipeline/lyrics_align.py
index fd63a35a..07ea24f7 100644
--- a/app/pipeline/lyrics_align.py
+++ b/app/pipeline/lyrics_align.py
@@ -44,6 +44,7 @@
LYRICS_ALIGN_TOLERANCE_SEC,
)
from app.core.models import Job
+from app.core.stem_files import stem_path
from app.pipeline.audio_stats import vocal_envelope
logger = logging.getLogger("stemdeck.lyrics")
@@ -194,7 +195,7 @@ def estimate_offset(starts: list[float], strength: np.ndarray, hop: float) -> fl
def _envelope(stems_dir: Path) -> tuple[float, list[int]] | None:
"""The vocals stem's envelope: the one kept beside it when it is not
older than the stem, else read from the stem now. None without vocals."""
- vocals = stems_dir / "vocals.wav"
+ vocals = stem_path(stems_dir, "vocals")
if not vocals.is_file():
return None
kept = stems_dir / _ENVELOPE_FILE
diff --git a/app/pipeline/lyrics_retime.py b/app/pipeline/lyrics_retime.py
index c9a7c669..1b0b8223 100644
--- a/app/pipeline/lyrics_retime.py
+++ b/app/pipeline/lyrics_retime.py
@@ -112,6 +112,7 @@
LYRICS_TRANSCRIPT_FILE,
LYRICS_USER_SYNCED_MAX_LINES,
)
+from app.core.stem_files import stem_path
from app.pipeline.lyrics_align import (
_HIT_RISE_DB,
_LOUD_PERCENTILE,
@@ -1098,13 +1099,12 @@ def _anchor(
if rate is not None:
rates.append(rate)
end = float(heard.ends[matched_at[points[-1][2]]])
- start = _line_start(lyric, heard, matched_at, toks, points, first_t, rate, voice)
+ start = _line_start(heard, matched_at, toks, points, first_t, rate, voice)
anchors[li] = (start, end, rate or 0.0)
return anchors, rates
def _line_start(
- lyric: Lyric,
heard: Heard,
matched_at: dict[int, int],
toks: list[int],
@@ -1458,12 +1458,29 @@ def _vocals_stamp(job_dir: Path) -> list[int] | None:
was heard from. A stem made again (a re-separation writing it in place)
no longer matches, and its transcript is heard afresh."""
try:
- st = (job_dir / "stems" / "vocals.wav").stat()
+ st = stem_path(job_dir / "stems", "vocals").stat()
except OSError:
return None
return [st.st_size, st.st_mtime_ns]
+def restamp_transcript(job_dir: Path, old_stamp: list[int]) -> None:
+ """The vocals stem was stored again in another format, same audio (#783):
+ a transcript heard from it (stamped ``old_stamp``) is moved onto the new
+ file's stamp, so it is not heard afresh. Anything else is left as it is."""
+ from app.pipeline.lyrics_lookup import _write_json_atomic
+
+ path = transcript_path(job_dir)
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return
+ if not isinstance(data, dict) or data.get("vocals") != old_stamp:
+ return
+ data["vocals"] = _vocals_stamp(job_dir)
+ _write_json_atomic(path, data)
+
+
def save_transcript(
job_dir: Path,
result: dict[str, Any] | None = None,
@@ -1820,7 +1837,7 @@ def _hear(
when the worker failed. Raises RetimeFailed without vocals, JobCancelled."""
from app.pipeline.transcribe import _device, _run_worker, _spawn_worker_cmd
- vocals = job_dir / "stems" / "vocals.wav"
+ vocals = stem_path(job_dir / "stems", "vocals")
if not vocals.is_file():
raise RetimeFailed("no vocals stem")
cmd = _spawn_worker_cmd(vocals, _device(job))
diff --git a/app/pipeline/mastering.py b/app/pipeline/mastering.py
new file mode 100644
index 00000000..e6556f5b
--- /dev/null
+++ b/app/pipeline/mastering.py
@@ -0,0 +1,464 @@
+"""Mastering of an export: loudness target, optional glue compressor and a
+true-peak limiter on the mix bus, rendered by ffmpeg.
+
+The design and its measurements are in docs/plans/0.21.0/01-mastering.md
+(section 4.1). In order, after the lanes are summed and the master EQ has run:
+
+ [glue compressor] -> volume (measured gain) -> 4x oversampled alimiter -> back
+
+1. Measure the mix as it reaches the master chain (ffmpeg ``ebur128``,
+ BS.1770 gated integrated loudness and 4x true peak). The job's stored
+ loudness is never reused: the mix depends on gains, mutes, EQ and takes.
+2. With the compressor on, its threshold sits relative to that measured
+ loudness (so an amount means the same on a quiet and a hot mix), and the
+ mix is measured again after it: a compressor lowers loudness, and a gain
+ computed before it lands short (01-mastering 4.1, measured 0.4 LU).
+3. Gain = target - measured, then the limiter at the ceiling. ``alimiter``
+ gets ``level=disabled`` (its default re-normalises to 0 dB) and runs at 4x
+ the rate, so its ceiling holds the true peak, not only the samples.
+4. The result is measured, and when limiting cost more than CORRECTION_TOL_LU
+ one correction pass adds the difference to the gain and measures again.
+
+The plan (the measurements and the final gain) is always made on the whole
+song, so a region export or a short "render and listen" excerpt gets exactly
+the gain of the full master. Pure functions here build filter strings and
+parse ffmpeg's output; ``run_plan`` runs the measurements.
+
+The ``master=`` export parameter is parsed here (``parse_master_param``):
+base64url JSON, size-capped, every field typed and range-checked, and only
+numbers formatted by this module reach the ffmpeg graph.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import binascii
+import json
+import logging
+import math
+import re
+from collections import deque
+from dataclasses import asdict, dataclass
+
+from app.core.config import (
+ EBUR128_FRAMELOG,
+ EXPORT_LIMIT_LOSSY_DBTP,
+ TIMEOUT_FFMPEG,
+ ffmpeg_executable,
+)
+
+logger = logging.getLogger("stemdeck.mastering")
+
+MASTER_WIRE_VERSION = 1
+MASTER_PARAM_MAX_CHARS = 256
+
+# Integrated loudness targets a user may ask for (LUFS). -24 is broadcast's
+# quiet end, -6 is louder than any streaming service plays back.
+TARGET_MIN, TARGET_MAX = -24.0, -6.0
+# True-peak ceilings (dBTP). Lossy exports never go above LOSSY_CEILING_DBTP
+# whatever the user asks: the encoder adds peak of its own (task B measured
+# up to +0.7 dB for MP3 and up to 2.7 dB for AAC with intensity stereo).
+CEILING_MIN, CEILING_MAX = -3.0, -0.1
+LOSSY_CEILING_DBTP = EXPORT_LIMIT_LOSSY_DBTP
+# Glue compressor amount: 0 off, 1 the strongest the MVP offers (3:1).
+COMP_MIN, COMP_MAX = 0.0, 1.0
+
+# The gain the master may apply. A near-silent mix is not raised 40 dB into
+# the limiter: past +20 dB the target is reported as not reached instead.
+GAIN_MAX_DB = 20.0
+GAIN_MIN_DB = -30.0
+# Correction passes when the limiter cost more than CORRECTION_TOL_LU: the
+# first adds the miss to the gain, the second steps along the measured slope
+# (dense material gains about 0.6 LU per dB once the limiter works: measured
+# on three real songs, one pass left -9.24, -9.30 and -9.04 for -9). Each step
+# is at most CORRECTION_MAX_DB: more gain into a limiter that is already
+# working hard buys less and less loudness.
+CORRECTION_TOL_LU = 0.1
+CORRECTION_MAX_DB = 3.0
+CORRECTION_PASSES = 2
+# ebur128 reports -70 LUFS (its absolute gate) for silence.
+_SILENCE_LUFS = -69.9
+
+OVERSAMPLE = 4
+# The limiter holds its 4x samples at the ceiling; the resample back to the
+# stem rate and a meter's own interpolation read up to 0.07 dB higher
+# (measured on three songs at -1 dBTP: -0.93 to -0.98). The limit sits this
+# far under the ceiling so the measured true peak does not cross it.
+LIMITER_MARGIN_DB = 0.1
+LIMITER_ATTACK_MS = 5
+LIMITER_RELEASE_MS = 50
+
+# Glue compressor, as a function of the amount a (0..1): ratio 1 + 2a (2:1 at
+# the default 0.5), threshold measured loudness + 2 - 6a dB (so loud passages,
+# a few dB over the average, get 1 to 3 dB of gain reduction), RMS detection
+# with a slow attack and release (bus "glue", not peak control: the limiter
+# does that), 6 dB soft knee, no makeup (the loudness gain is the makeup).
+COMP_ATTACK_MS = 20
+COMP_RELEASE_MS = 250
+COMP_KNEE_DB = 6.0
+COMP_THRESHOLD_FLOOR_DB = -60.0 # acompressor's own range ends here
+
+_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]*$")
+_I_RE = re.compile(r"lavfi\.r128\.I=(\S+)")
+_TP_RE = re.compile(r"lavfi\.r128\.true_peak=(\S+)")
+_LRA_RE = re.compile(r"lavfi\.r128\.LRA=(\S+)")
+
+
+class MasterError(ValueError):
+ """A malformed or out-of-range ``master`` value. The message is safe to show."""
+
+
+@dataclass(frozen=True)
+class MasterSettings:
+ """What the user asked for. ``target`` None: no loudness change, only the
+ limiter ("prevent clipping")."""
+
+ target: float | None
+ ceiling: float
+ comp: float = 0.0
+
+ def fragment(self) -> str:
+ t = "none" if self.target is None else f"{self.target:.1f}"
+ return f"t={t};c={self.ceiling:.1f};k={self.comp:.2f}"
+
+
+@dataclass(frozen=True)
+class Loudness:
+ i: float | None # integrated LUFS, None for silence
+ tp: float | None # true peak dBTP, None for digital silence
+ lra: float | None = None
+
+
+@dataclass(frozen=True)
+class MasterPlan:
+ """The measured master of one mix: what the render applies and reports."""
+
+ target: float | None
+ ceiling: float
+ comp: float
+ pre: Loudness # the mix before the master chain (after the master EQ)
+ comp_threshold_db: float | None # None: compressor off
+ comp_i: float | None # loudness after the compressor (None when off)
+ gain_db: float
+ post: Loudness # the mastered result, measured before the encoder
+ reached: bool # within CORRECTION_TOL_LU of the target (True with no target)
+ passes: int # measurement passes run
+
+ def to_json(self) -> dict:
+ return asdict(self)
+
+ @classmethod
+ def from_json(cls, data: dict) -> MasterPlan:
+ return cls(
+ target=data["target"],
+ ceiling=data["ceiling"],
+ comp=data["comp"],
+ pre=Loudness(**data["pre"]),
+ comp_threshold_db=data["comp_threshold_db"],
+ comp_i=data["comp_i"],
+ gain_db=data["gain_db"],
+ post=Loudness(**data["post"]),
+ reached=data["reached"],
+ passes=data["passes"],
+ )
+
+
+# ---------------------------------------------------------------------------
+# The master= parameter
+# ---------------------------------------------------------------------------
+
+
+def _reject_constant(name: str) -> float:
+ raise MasterError("master: numbers must be finite")
+
+
+def _number(v: object, lo: float, hi: float, what: str) -> float:
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ raise MasterError(f"master: {what} must be a number")
+ x = float(v)
+ if not math.isfinite(x) or x < lo or x > hi:
+ raise MasterError(f"master: {what} out of range")
+ return x
+
+
+def parse_master_param(raw: str | None) -> MasterSettings | None:
+ """Decode and validate ``master=``: base64url (no padding) of
+ ``{"v": 1, "target": | null, "ceiling": , "comp": <0..1>}``.
+ Empty means mastering off (None). Raises MasterError."""
+ if not raw:
+ return None
+ if len(raw) > MASTER_PARAM_MAX_CHARS or not _B64URL_RE.match(raw):
+ raise MasterError("master: malformed")
+ try:
+ text = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)).decode("utf-8")
+ body = json.loads(text, parse_constant=_reject_constant)
+ except MasterError:
+ raise
+ except (binascii.Error, UnicodeDecodeError, ValueError, RecursionError):
+ raise MasterError("master: malformed") from None
+ if not isinstance(body, dict) or set(body) - {"v", "target", "ceiling", "comp"}:
+ raise MasterError("master: malformed")
+ if isinstance(body.get("v"), bool) or body.get("v") != MASTER_WIRE_VERSION:
+ raise MasterError("master: unsupported version")
+ if "target" not in body or "ceiling" not in body:
+ raise MasterError("master: target and ceiling are required")
+ target = body["target"]
+ if target is not None:
+ target = round(_number(target, TARGET_MIN, TARGET_MAX, "target"), 1)
+ ceiling = round(_number(body["ceiling"], CEILING_MIN, CEILING_MAX, "ceiling"), 1)
+ comp = round(_number(body.get("comp", 0), COMP_MIN, COMP_MAX, "compressor"), 2)
+ return MasterSettings(target=target, ceiling=ceiling, comp=comp)
+
+
+def encode_master_param(settings: MasterSettings) -> str:
+ """The wire form (tests and scripts; the browser builds the same)."""
+ body = {"v": MASTER_WIRE_VERSION, "target": settings.target, "ceiling": settings.ceiling}
+ if settings.comp:
+ body["comp"] = settings.comp
+ raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+
+def effective_ceiling(settings: MasterSettings, lossy: bool) -> float:
+ """The ceiling the render uses: never above LOSSY_CEILING_DBTP for a lossy
+ encoder."""
+ return min(settings.ceiling, LOSSY_CEILING_DBTP) if lossy else settings.ceiling
+
+
+# ---------------------------------------------------------------------------
+# Filter strings
+# ---------------------------------------------------------------------------
+
+
+def _lin(db: float) -> str:
+ return f"{10.0 ** (db / 20.0):.8f}"
+
+
+def comp_threshold_db(comp: float, pre_i: float | None) -> float | None:
+ """The compressor threshold for a measured loudness, None when off."""
+ if comp <= 0:
+ return None
+ base = pre_i if pre_i is not None else -40.0
+ return round(min(0.0, max(COMP_THRESHOLD_FLOOR_DB, base + 2.0 - 6.0 * comp)), 2)
+
+
+def comp_ratio(comp: float) -> float:
+ return round(1.0 + 2.0 * comp, 3)
+
+
+def comp_filter(comp: float, threshold_db: float | None) -> str:
+ """``acompressor`` for the glue compressor, "" when off."""
+ if comp <= 0 or threshold_db is None:
+ return ""
+ knee = 10.0 ** (COMP_KNEE_DB / 20.0)
+ return (
+ f"acompressor=threshold={_lin(threshold_db)}:ratio={comp_ratio(comp)}"
+ f":attack={COMP_ATTACK_MS}:release={COMP_RELEASE_MS}:knee={knee:.6f}"
+ ":detection=rms:link=average:makeup=1"
+ )
+
+
+def limiter_filter(ceiling_db: float, rate: int) -> str:
+ """True-peak limiter: alimiter at OVERSAMPLE x the rate, back to the rate."""
+ return (
+ f"aresample={OVERSAMPLE * rate},"
+ f"alimiter=limit={_lin(ceiling_db - LIMITER_MARGIN_DB)}:attack={LIMITER_ATTACK_MS}"
+ f":release={LIMITER_RELEASE_MS}:level=disabled:latency=1,"
+ f"aresample={rate}"
+ )
+
+
+def master_chain(
+ comp: float, threshold_db: float | None, gain_db: float, ceiling_db: float, rate: int
+) -> str:
+ """The whole master chain after the master EQ, without a trailing comma."""
+ parts = [comp_filter(comp, threshold_db)]
+ if gain_db != 0:
+ parts.append(f"volume={_lin(gain_db)}")
+ parts.append(limiter_filter(ceiling_db, rate))
+ return ",".join(p for p in parts if p)
+
+
+def plan_chain(plan: MasterPlan, rate: int) -> str:
+ return master_chain(plan.comp, plan.comp_threshold_db, plan.gain_db, plan.ceiling, rate)
+
+
+def meter_filter(rate: int) -> str:
+ """Appended to a chain to measure it: one-second frames (so a long song
+ prints a line a second, not 43), ebur128 with its cumulative values as
+ frame metadata, printed. The last values cover the whole signal."""
+ return (
+ f"asetnsamples=n={rate}:p=0,"
+ f"ebur128=peak=true:metadata=1:framelog={EBUR128_FRAMELOG},"
+ "ametadata=mode=print:key=lavfi.r128.I,"
+ "ametadata=mode=print:key=lavfi.r128.LRA,"
+ "ametadata=mode=print:key=lavfi.r128.true_peak"
+ )
+
+
+def _last_float(regex: re.Pattern[str], text: str) -> float | None:
+ found = regex.findall(text)
+ if not found:
+ return None
+ try:
+ value = float(found[-1])
+ except ValueError:
+ return None
+ return value if math.isfinite(value) else None
+
+
+def parse_meter(text: str) -> Loudness:
+ """Loudness from ``meter_filter``'s printed metadata."""
+ i = _last_float(_I_RE, text)
+ if i is not None and i <= _SILENCE_LUFS:
+ i = None
+ tp_lin = _last_float(_TP_RE, text)
+ tp = 20.0 * math.log10(tp_lin) if tp_lin and tp_lin > 0 else None
+ lra = _last_float(_LRA_RE, text)
+ return Loudness(
+ i=None if i is None else round(i, 3),
+ tp=None if tp is None else round(tp, 3),
+ lra=None if lra is None else round(lra, 3),
+ )
+
+
+def plan_graph(lane_chains: list[str], master_eq: str) -> str:
+ """The filter graph of the whole song up to the master chain, ending in
+ the label [pre]: input i through ``lane_chains[i]`` (its EQ, pitch shift
+ and gain, no trim, no delay), summed, then the master EQ (``master_eq`` is
+ "" or a prefix ending in a comma). No click: the click is a practice aid,
+ not part of the music the loudness is set for."""
+ filters = [f"[{i}:a]{chain}[a{i}]" for i, chain in enumerate(lane_chains)]
+ n = len(lane_chains)
+ if n > 1:
+ labels = "".join(f"[a{i}]" for i in range(n))
+ filters.append(f"{labels}amix=inputs={n}:normalize=0,{master_eq}anull[pre]")
+ else:
+ filters.append(f"[a0]{master_eq}anull[pre]")
+ return ";".join(filters)
+
+
+def clamp_gain(gain_db: float) -> float:
+ return round(min(GAIN_MAX_DB, max(GAIN_MIN_DB, gain_db)), 4)
+
+
+# ---------------------------------------------------------------------------
+# Running the plan
+# ---------------------------------------------------------------------------
+
+
+class MeasureError(RuntimeError):
+ """ffmpeg failed while measuring. The message is for the log only."""
+
+
+async def _measure(inputs: list[str], graph: str, chain: str, rate: int) -> Loudness:
+ """Run ``graph`` (whose output label is [pre]) through ``chain`` and the
+ meter, to a null output. ``inputs`` are ffmpeg's -i arguments."""
+ tail = f"{chain}," if chain else ""
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-hide_banner",
+ "-nostats",
+ "-loglevel",
+ "info",
+ *inputs,
+ "-filter_complex",
+ f"{graph};[pre]{tail}{meter_filter(rate)}[out]",
+ "-map",
+ "[out]",
+ "-f",
+ "null",
+ "-",
+ ]
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE
+ )
+ try:
+ _out, err = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG)
+ except TimeoutError:
+ proc.kill()
+ await proc.wait()
+ raise MeasureError("loudness measurement timed out") from None
+ finally:
+ # A cancelled request (the client went away) unwinds through here.
+ if proc.returncode is None:
+ proc.kill()
+ await proc.wait()
+ text = (err or b"").decode("utf-8", "replace")
+ if proc.returncode != 0:
+ tail_lines = deque(text.splitlines(), maxlen=8)
+ raise MeasureError(f"ffmpeg exit {proc.returncode}: {' | '.join(tail_lines)}")
+ return parse_meter(text)
+
+
+async def measure_file(path: str) -> Loudness:
+ """Loudness and true peak of a rendered file, decoded (a lossy file is
+ measured as a player would play it)."""
+ return await _measure(["-i", path], "[0:a]anull[pre]", "", 44100)
+
+
+def trim_filter(trim_db: float) -> str:
+ """A level trim after the limiter (the lossy retry), "" for none."""
+ return f",volume={_lin(-trim_db)}" if trim_db > 0 else ""
+
+
+async def run_plan(
+ inputs: list[str], graph: str, settings: MasterSettings, ceiling_db: float, rate: int
+) -> MasterPlan:
+ """Measure the mix and decide the gain. ``graph`` is the filter graph of
+ the whole song up to the master chain, ending in the label [pre]."""
+ pre = await _measure(inputs, graph, "", rate)
+ passes = 1
+ threshold = comp_threshold_db(settings.comp, pre.i)
+ comp_i = None
+ level = pre.i
+ if threshold is not None:
+ after = await _measure(inputs, graph, comp_filter(settings.comp, threshold), rate)
+ passes += 1
+ comp_i = after.i
+ level = after.i
+ # Prevent clipping only, or silence: no gain, the limiter alone.
+ no_gain = settings.target is None or level is None
+ gain = 0.0 if no_gain else clamp_gain(settings.target - level)
+ chain = master_chain(settings.comp, threshold, gain, ceiling_db, rate)
+ post = await _measure(inputs, graph, chain, rate)
+ passes += 1
+ tried = [(gain, post.i)]
+ for _ in range(0 if no_gain else CORRECTION_PASSES):
+ if post.i is None:
+ break
+ miss = settings.target - post.i
+ if abs(miss) <= CORRECTION_TOL_LU:
+ break
+ slope = 1.0
+ if len(tried) > 1:
+ (g0, l0), (g1, l1) = tried[-2], tried[-1]
+ if g1 != g0 and l0 is not None and l1 is not None:
+ # Between 0.1 (a limiter at its wall) and 1 (no limiting).
+ slope = min(1.0, max(0.1, (l1 - l0) / (g1 - g0)))
+ step = max(-CORRECTION_MAX_DB, min(CORRECTION_MAX_DB, miss / slope))
+ fixed = clamp_gain(gain + step)
+ if fixed == gain:
+ break
+ gain = fixed
+ chain = master_chain(settings.comp, threshold, gain, ceiling_db, rate)
+ post = await _measure(inputs, graph, chain, rate)
+ passes += 1
+ tried.append((gain, post.i))
+ reached = settings.target is None or (
+ post.i is not None and abs(settings.target - post.i) <= CORRECTION_TOL_LU + 1e-9
+ )
+ return MasterPlan(
+ target=settings.target,
+ ceiling=ceiling_db,
+ comp=settings.comp,
+ pre=pre,
+ comp_threshold_db=threshold,
+ comp_i=comp_i,
+ gain_db=gain,
+ post=post,
+ reached=reached,
+ passes=passes,
+ )
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 4f06fcec..5d737baf 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -7,7 +7,7 @@
import subprocess
import time
import traceback
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from pathlib import Path
from app.core.config import (
@@ -19,8 +19,9 @@
)
from app.core.models import Job, JobCancelled, _set, settle_cancelled
from app.core.redact import redact
-from app.core.registry import is_upload
+from app.core.registry import is_upload, keep_added_time, mark_stems_complete, saved_fields
from app.core.registry import persist as persist_registry
+from app.pipeline import jobqueue
from app.pipeline.analyze import analyze, refine_key_from_stems
from app.pipeline.beatgrid import compute_beat_grid
from app.pipeline.cancel import check_cancel as _check_cancel
@@ -40,6 +41,7 @@
from app.pipeline.lyrics_retime import retime_after_separation
from app.pipeline.sections import detect_sections
from app.pipeline.separate import separate
+from app.pipeline.stem_storage import store_stems
from app.pipeline.transcribe import transcribe_lyrics
logger = logging.getLogger("stemdeck.pipeline")
@@ -148,11 +150,10 @@ def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path:
def _lap(job: Job, name: str, start: float) -> float:
"""Record a stage duration in job.stage_timings; returns a new start mark.
Timings feed the one-line completion summary, metadata.json, and the
- failure quarantine's error.txt (#293)."""
+ failure quarantine's error.txt (#293). A new dict each time: a registry
+ save reads the old one from another thread, without a lock this takes."""
now = time.monotonic()
- if job.stage_timings is None:
- job.stage_timings = {}
- job.stage_timings[name] = round(now - start, 1)
+ job.stage_timings = {**(job.stage_timings or {}), name: round(now - start, 1)}
return now
@@ -179,7 +180,7 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
_check_cancel(job)
stems_root = separate(job, source, job_dir)
mark = _lap(job, "separate", mark)
- found = collect(job, stems_root, job_dir)
+ found = collect(stems_root, job_dir)
stems_dir = job_dir / "stems"
# Deleting the source is the bulk of disk reclaim per job, and it happens
# before the ffmpeg amix steps below in case scratch space is tight. But
@@ -282,13 +283,21 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
failure raises out of _run_common, past finish(), and nothing is written.
"""
lookup = IdentifyLookup.start(job, [source])
+ # Lyrics only when Detect lyrics was on for this job (False skips all
+ # three stages below; None, a job from before the setting, looks as it
+ # always did).
+ want_lyrics = job.auto_lyrics is not False
# The lyrics, on the same terms: LRCLIB beside separation, lyrics.json
# written only by a pipeline that got to the end. Looked up by the
# identity once the identification has it, else by the tags.
- lyrics = LyricsLookup.start(
- job,
- job_dir,
- identity=(lambda: lookup.wait_identity(LYRICS_IDENTITY_WAIT_SEC)) if lookup else None,
+ lyrics = (
+ LyricsLookup.start(
+ job,
+ job_dir,
+ identity=(lambda: lookup.wait_identity(LYRICS_IDENTITY_WAIT_SEC)) if lookup else None,
+ )
+ if want_lyrics
+ else None
)
_run_common(job, source, job_dir)
if lookup is not None:
@@ -300,6 +309,8 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
mark = time.monotonic()
lyrics.finish(job, job_dir, LYRICS_LOOKUP_GRACE_SEC)
_lap(job, "lyrics_wait", mark)
+ if not want_lyrics:
+ return
# Lyrics from the vocals stem, only when the lookup found none: last,
# because it needs both the stem and the lookup's answer. Never raises
# but for a cancel.
@@ -314,12 +325,42 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
_lap(job, "retime", mark)
+def _store_stems(job: Job, job_dir: Path) -> None:
+ """WAV or MP3 storage of the stems (#783). Last, once no stage reads the
+ stems any more: separation and every analysis ran on the WAVs. Never
+ fails the song; store_stems keeps the WAVs on any failure, and the guard
+ is for a future change there, as for the beat grid."""
+ _check_cancel(job)
+ mark = time.monotonic()
+ try:
+ store_stems(job, job_dir)
+ except JobCancelled:
+ raise
+ except Exception:
+ logger.exception("stem storage stage failed for job %s; stems kept as WAV", job.id)
+ _set(job, stem_format="wav", stem_mp3_kbps=None)
+ _lap(job, "store", mark)
+
+
+def _mark_stems_complete(job: Job, job_dir: Path) -> None:
+ """Last: nothing writes into stems/ after it. A failure costs the marker,
+ not the song, whose stems are whole: failing the job would delete them.
+ A song without the marker is rebuilt by the metadata.json it gets once
+ done, as a song made before the marker is."""
+ try:
+ mark_stems_complete(job_dir)
+ except OSError:
+ logger.exception("[%s] could not mark the stems complete", job.id)
+
+
def _run_blocking(job: Job, url: str, job_dir: Path) -> None:
_check_cancel(job)
mark = time.monotonic()
source = download(job, url, job_dir)
_lap(job, "download", mark)
_run_with_band_lookup(job, source, job_dir)
+ _store_stems(job, job_dir)
+ _mark_stems_complete(job, job_dir)
def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None:
@@ -328,6 +369,8 @@ def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None:
source = _prepare_local_source(job, source_path, job_dir)
_lap(job, "prepare", mark)
_run_with_band_lookup(job, source, job_dir)
+ _store_stems(job, job_dir)
+ _mark_stems_complete(job, job_dir)
def _write_metadata(job: Job, job_dir: Path) -> None:
@@ -346,6 +389,8 @@ def _write_metadata(job: Job, job_dir: Path) -> None:
"lufs": job.lufs,
"peak_db": job.peak_db,
"dynamic_range": job.dynamic_range,
+ "loudness_version": job.loudness_version,
+ "loudness_remeasured": job.loudness_remeasured,
"tempo_stability": job.tempo_stability,
"stem_presence": job.stem_presence,
"sections": job.sections,
@@ -356,6 +401,9 @@ def _write_metadata(job: Job, job_dir: Path) -> None:
"compute_device": job.compute_device,
"gpu_fallback": job.gpu_fallback,
"stage_timings": job.stage_timings,
+ "stem_format": job.stem_format,
+ "stem_mp3_kbps": job.stem_mp3_kbps,
+ **saved_fields(job),
}
try:
(job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
@@ -363,9 +411,10 @@ def _write_metadata(job: Job, job_dir: Path) -> None:
logger.warning("could not write metadata.json for job %s", job.id, exc_info=True)
-# Files worth keeping in a quarantined failure dir. Everything else (source
-# download, stem WAVs, video) is deleted first -- evidence must stay KB-scale,
-# not GB-scale.
+# Files worth keeping in a quarantined failure dir. Everything else (a link's
+# source download, stem WAVs, video) is deleted first -- evidence must stay
+# KB-scale, not GB-scale. An upload's source.* is kept as well (see
+# _quarantine_failed_job).
_QUARANTINE_KEEP = frozenset(("error.txt", "metadata.json"))
@@ -376,7 +425,13 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
tail, full traceback), strips the heavy audio payloads, and moves the dir
to jobs/failed/ where sweep_failed_jobs expires it after FAILED_TTL.
Best-effort throughout: any step failing falls back to plain removal so a
- pathological error can never leak disk."""
+ pathological error can never leak disk.
+
+ Except for an upload's source.*. It goes to the quarantine with the
+ evidence, and if the quarantine itself fails its folder is left where it
+ is. Same reason the runner never deletes it on success: a link can be
+ fetched again, an upload is the one copy StemDeck has, and a failure (out
+ of memory, disk full, a killed worker) says nothing about the file."""
tail: list[str] = getattr(exc, "tail", None) or []
cause = classify_failure("\n".join([*tail, repr(exc)]))
detail = cause
@@ -394,6 +449,18 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
job.error_detail = detail
try:
+ # Strip heavy payloads: the quarantine keeps diagnostics, not audio.
+ # First, so an upload's folder left behind by a failure below (disk
+ # full writing error.txt) holds no stems for restore() to adopt.
+ keep_source = is_upload(job)
+ for p in list(job_dir.iterdir()):
+ if p.name in _QUARANTINE_KEEP or (keep_source and p.name.startswith("source.")):
+ continue
+ if p.is_dir():
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ p.unlink(missing_ok=True)
+
# title/source stay unredacted: they never leave this file (the
# /failure API's allowlist excludes both, see app/api/jobs.py), so
# this is purely local diagnostic value for the person looking at
@@ -403,7 +470,7 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
# URL or the reporter's home directory.
redacted_tail = [redact(line) for line in tail]
lines = [
- f"time: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
+ f"time: {datetime.now(UTC).isoformat(timespec='seconds')}",
f"job: {job.id}",
f"title: {job.title or '(unknown)'}",
f"source: {job.source_url or '(unknown)'}",
@@ -421,15 +488,6 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
lines += ["", "--- traceback ---", tb]
(job_dir / "error.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
- # Strip heavy payloads: the quarantine keeps diagnostics, not audio.
- for p in list(job_dir.iterdir()):
- if p.name in _QUARANTINE_KEEP:
- continue
- if p.is_dir():
- shutil.rmtree(p, ignore_errors=True)
- else:
- p.unlink(missing_ok=True)
-
failed_root = jobs_dir / "failed"
failed_root.mkdir(parents=True, exist_ok=True)
dest = failed_root / job.id
@@ -438,6 +496,11 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
shutil.move(str(job_dir), str(dest))
logger.info("[%s] failure evidence kept at %s", job.id, dest)
except Exception:
+ if is_upload(job):
+ logger.warning(
+ "[%s] quarantine failed; upload left at %s", job.id, job_dir, exc_info=True
+ )
+ return
logger.warning("[%s] quarantine failed; removing job dir", job.id, exc_info=True)
_rmtree(job_dir)
@@ -452,33 +515,58 @@ async def _run_async(
) -> None:
"""Common async wrapper: acquires the pipeline lock, runs blocking_fn in a
thread, then handles success / cancel / error outcomes uniformly."""
+ # Before the run writes into the folder and moves its time: a link's
+ # folder is made just now, and a song added before the add time was kept
+ # gets it here.
+ await asyncio.to_thread(keep_added_time, job, job_dir)
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:
+ if jobqueue.is_stopping():
+ # The shutdown killed the demucs worker under this job, which
+ # reads exactly like a crash. Failing it deleted an upload. Its
+ # status stays resumable and the next start queues it again.
+ logger.info("[%s] interrupted by shutdown: %s", job.id, e)
+ return
logger.exception("pipeline failed for job %s: %s", job.id, e)
- _set(job, status="error", stage="Error: Processing failed", error=error_msg)
# A fingerprint may still have the source open (Windows).
release_source(job.id)
- _quarantine_failed_job(job, job_dir, jobs_dir, e)
- persist_registry(jobs_dir)
+ # Off the loop: stripping and moving a multi-GB folder on a NAS
+ # stalled every stream and request. The status changes after it,
+ # so the first "error" a page sees already carries error_detail
+ # and nothing can delete the folder mid-move.
+ await asyncio.to_thread(_quarantine_failed_job, job, job_dir, jobs_dir, e)
+ _set(job, status="error", stage="Error: Processing failed", error=error_msg)
+ # Emptied from the Trash while its folder moved. Before the
+ # persist, which forgets a deletion whose folder is gone.
+ await jobqueue.finish_deferred_delete(job, jobs_dir)
+ await asyncio.to_thread(persist_registry, jobs_dir)
return
logger.info(
"pipeline cancelled%s for job %s",
" (wrapped)" if not isinstance(e, JobCancelled) else "",
job.id,
)
+ if job.stop_requested:
+ # A stopped job keeps its source, in the Library (#772) or the
+ # Trash (#748). Its half-written separation is of no use and can
+ # be large; Extract clears the same folder (reset_for_rerun).
+ # Cleared before the status changes, so nothing that sees
+ # "stopped" can still find it.
+ await asyncio.to_thread(shutil.rmtree, job_dir / DEMUCS_MODEL, ignore_errors=True)
remove_files = settle_cancelled(job)
- persist_registry(jobs_dir)
+ await asyncio.to_thread(persist_registry, jobs_dir)
release_source(job.id)
# A job stopped from the Trash keeps its files until the Trash is
# emptied (#748). A plain cancel's folder is retried like delete_job's
# removal: right after the cancel terminated ffmpeg or the demucs
# worker, Windows can still hold one of its files, and a single attempt
# left the folder behind for good.
- if remove_files and not remove_job_dir(job_dir):
+ if remove_files and not await asyncio.to_thread(remove_job_dir, job_dir):
logger.warning("[%s] cancelled job's folder could not be removed", job.id)
+ await jobqueue.finish_deferred_delete(job, jobs_dir)
return
# A cancel or a trash that arrived after the last stage checked for it lost
# the race, and the job finished anyway. Left set, the flags would make
@@ -488,7 +576,9 @@ async def _run_async(
job.stop_requested = False
_set(job, status="done", progress=1.0, stage="Done")
_write_metadata(job, job_dir)
- persist_registry(jobs_dir)
+ await asyncio.to_thread(persist_registry, jobs_dir)
+ # Emptied from the Trash while it finished anyway.
+ await jobqueue.finish_deferred_delete(job, jobs_dir)
# One-line per-job summary: the timing telemetry that makes performance
# regressions (and the CPU-vs-GPU question) answerable from logs (#293).
t = job.stage_timings or {}
@@ -514,7 +604,7 @@ async def run_pipeline(job: Job, url: str, jobs_dir: Path) -> None:
stage="Error: Processing failed",
error="Audio processing failed. Please try another video.",
)
- persist_registry(jobs_dir)
+ await asyncio.to_thread(persist_registry, jobs_dir)
return
await _run_async(
job,
diff --git a/app/pipeline/sections.py b/app/pipeline/sections.py
index 0a994f16..ddb13af4 100644
--- a/app/pipeline/sections.py
+++ b/app/pipeline/sections.py
@@ -30,7 +30,7 @@
ffmpeg_executable,
)
from app.core.models import Job, JobCancelled
-from app.core.registry import set_proc
+from app.core.registry import job_dirs, set_proc
logger = logging.getLogger("stemdeck.sections")
@@ -388,10 +388,10 @@ def sweep_orphaned_workspaces(jobs_dir: Path) -> int:
"""
removed = 0
try:
- job_dirs = list(jobs_dir.iterdir())
+ folders = job_dirs(jobs_dir)
except OSError:
return 0
- for job_dir in job_dirs:
+ for job_dir in folders:
stems_dir = job_dir / "stems"
try:
candidates = list(stems_dir.iterdir()) if stems_dir.is_dir() else []
diff --git a/app/pipeline/separate.py b/app/pipeline/separate.py
index 52cd4d6a..1da0000d 100644
--- a/app/pipeline/separate.py
+++ b/app/pipeline/separate.py
@@ -11,15 +11,19 @@
import time
from pathlib import Path
-from app.core.config import DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL
+from app.core.config import DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL, TIMEOUT_MODEL_STALL
from app.core.models import Job, JobCancelled, _set
from app.core.registry import set_proc
from app.core.settings import get_demucs_device, get_separation_quality
+from app.pipeline import idle_unload, jobqueue
from app.pipeline.errors import SeparationError, classify_failure
+from app.pipeline.idle_unload import IdleResource
logger = logging.getLogger("stemdeck.pipeline")
_PCT_RE = re.compile(r"(\d{1,3})%")
+# The line torch.hub writes before it fetches a checkpoint.
+_DOWNLOADING_RE = re.compile(r'^Downloading: "(?P[^"]+)" to (?P.+)$')
# 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.).
@@ -28,10 +32,16 @@
# in runner.py), so there is exactly one worker to track, not a pool. Reused
# across consecutive successful jobs on the same device; torn down on cancel,
# a device change, or any job failure -- see demucs_worker.py's docstring for
-# why a failure always kills the worker rather than trying to keep serving.
+# why a failure always kills the worker rather than trying to keep serving --
+# and stopped by the idle timer when no job has used it for a while (#975).
_worker: dict[str, object] = {}
+def _worker_alive() -> bool:
+ proc = _worker.get("proc")
+ return proc is not None and proc.poll() is None
+
+
def _spawn_worker_cmd(device: str) -> list[str]:
"""Build the persistent-worker invocation. Module-level seam so tests can
swap in a stub executable without touching the process-management
@@ -39,20 +49,161 @@ def _spawn_worker_cmd(device: str) -> list[str]:
return [sys.executable, "-m", "app.pipeline.demucs_worker", device]
+# How long a killed worker gets to exit before it is given up on. The idle
+# lock is held across a stop (idle_unload.py), up to 15 s in all: 5 s for
+# terminate() and this for kill(). The next job and shutdown wait on that
+# lock, so it must end even for a worker wedged in a CUDA call.
+_KILL_REAP_TIMEOUT_SEC = 10
+
+
+class _LeftBehind:
+ """A worker given up on after kill(), with its device."""
+
+ __slots__ = ("proc", "device", "since", "kills", "released")
+
+ def __init__(self, proc: subprocess.Popen, device: str) -> None:
+ self.proc = proc
+ self.device = device
+ self.since = time.monotonic()
+ self.kills = 0
+ self.released = False
+
+
+# Workers given up on after kill(). Still counted, so the next job does not
+# start a second GPU worker beside one that may still hold the card's memory,
+# and shutdown tries them once more. The lock guards the list only and is
+# never held across a wait.
+_left_behind: list[_LeftBehind] = []
+_left_behind_lock = threading.Lock()
+# After this many more kills, or this long, a leftover stops blocking the GPU:
+# one that never exits must not turn GPU separation off for the life of the
+# process. The next GPU worker is started beside it, and if memory runs out
+# the ordinary GPU-to-CPU retry in separate() takes over.
+_LEFT_BEHIND_MAX_KILLS = 3
+_LEFT_BEHIND_MAX_SEC = 600
+
+
+def _reap_left_behind() -> set[str]:
+ """Forget the left-behind workers that have exited, kill the rest again.
+ Returns the devices whose leftover still blocks a new worker."""
+ with _left_behind_lock:
+ entries = list(_left_behind)
+ blocking: set[str] = set()
+ for entry in entries:
+ proc = entry.proc
+ if proc.poll() is not None:
+ logger.info("left-behind demucs worker %s (%s) has exited", proc.pid, entry.device)
+ with _left_behind_lock:
+ if entry in _left_behind:
+ _left_behind.remove(entry)
+ _close_pipes(proc)
+ continue
+ if entry.released:
+ continue
+ if (
+ entry.kills >= _LEFT_BEHIND_MAX_KILLS
+ or time.monotonic() - entry.since >= _LEFT_BEHIND_MAX_SEC
+ ):
+ entry.released = True
+ logger.error(
+ "left-behind demucs worker %s (%s) survived %d kills; no longer holding %s"
+ " jobs back, the next one starts beside it",
+ proc.pid,
+ entry.device,
+ entry.kills,
+ entry.device,
+ )
+ continue
+ logger.warning(
+ "left-behind demucs worker %s (%s) is still running; killing again",
+ proc.pid,
+ entry.device,
+ )
+ entry.kills += 1
+ try:
+ proc.kill()
+ except OSError:
+ pass
+ blocking.add(entry.device)
+ return blocking
+
+
def _kill_worker() -> None:
proc = _worker.pop("proc", None)
- _worker.pop("device", None)
- if proc is not None and proc.poll() is None:
+ device = _worker.pop("device", None)
+ if proc is None:
+ return
+ if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
- # kill() only sends the signal. Without the reap a worker wedged in
- # an uninterruptible CUDA call becomes a zombie whose pipes are
- # closed only incidentally, whenever the Popen refcount happens to
- # drop. vocal_split.py already pairs the two.
- proc.communicate()
+ # kill() only sends the signal; the wait reaps it, so it does not
+ # stay a zombie. Bounded, unlike the communicate() this replaced:
+ # a worker wedged in an uninterruptible call must not hold the
+ # idle lock, and with it the next job and shutdown, forever.
+ try:
+ proc.wait(timeout=_KILL_REAP_TIMEOUT_SEC)
+ except subprocess.TimeoutExpired:
+ logger.error(
+ "demucs worker %s did not exit %s s after kill; left behind",
+ proc.pid,
+ _KILL_REAP_TIMEOUT_SEC,
+ )
+ # Its pipes stay open: a job thread may still be blocked
+ # reading stderr, and closing under it would block here.
+ with _left_behind_lock:
+ _left_behind.append(_LeftBehind(proc, str(device or "")))
+ return
+ _close_pipes(proc)
+
+
+def _close_pipes(proc: subprocess.Popen) -> None:
+ """Close an exited worker's pipes now, rather than whenever the Popen is
+ collected (a ResourceWarning under -X dev, and a handle held until then)."""
+ for pipe in (proc.stdin, proc.stderr):
+ if pipe is None:
+ continue
+ try:
+ pipe.close()
+ except OSError:
+ # stdin's close flushes, and the reader end is gone.
+ pass
+
+
+# Stopped by the idle timer after the Settings' idle minutes without a
+# separation (#975); the next separate() starts it again through _get_worker().
+# A dead worker counts as unloaded: the next job replaces it either way.
+# Lambdas, so a test that replaces _kill_worker replaces it here too.
+WORKER = idle_unload.register(
+ IdleResource("separation worker", lambda: _worker_alive(), lambda: _kill_worker())
+)
+
+
+def shutdown_worker() -> None:
+ """Stop the worker at server shutdown, even under a running job: the
+ runner reads that as an interruption (jobqueue.is_stopping), not a
+ failure. Through the idle lock, so an idle unload in progress finishes
+ first and the two never stop the same process at once. A worker given up
+ on earlier gets one more kill, and is logged if it still runs."""
+ WORKER.unload_now()
+ # A snapshot: a job thread's _kill_worker can still add one after this
+ # loop. That one, and any of these the kill does not end, has the
+ # worker's parent-PID watchdog as its backstop (STEMDECK_PARENT_PID): it
+ # exits once this process is gone, unless it is wedged in the kernel,
+ # where nothing from here could reach it either.
+ with _left_behind_lock:
+ entries = list(_left_behind)
+ for entry in entries:
+ if entry.proc.poll() is None:
+ try:
+ entry.proc.kill()
+ except OSError:
+ pass
+ logger.error(
+ "demucs worker %s (%s) still running at shutdown", entry.proc.pid, entry.device
+ )
def _get_worker(device: str) -> subprocess.Popen:
@@ -110,6 +261,13 @@ def _run_demucs(job: Job, source: Path, job_dir: Path, device: str) -> tuple[int
Returns (returncode, stderr_tail). Raises JobCancelled when the exit was
caused by POST /cancel. The retry policy lives in separate()."""
+ # GPU only: one check per GPU attempt, so the kill count below counts jobs.
+ if device != "cpu" and device in _reap_left_behind():
+ # A worker given up on after kill() may still hold the GPU's memory,
+ # and a second one beside it fails on memory. A failure, so
+ # separate() retries on the CPU; the reason is in the tail and the
+ # WARNING log, while the stage line shows the ordinary GPU retry.
+ return 1, [f"a previous separation worker on {device} has not exited yet"]
spawn_at = time.monotonic()
proc = _get_worker(device)
if proc.stdin is None or proc.stderr is None:
@@ -120,7 +278,9 @@ def _run_demucs(job: Job, source: Path, job_dir: Path, device: str) -> tuple[int
raise RuntimeError("demucs worker has no stdin/stderr pipe")
set_proc(job.id, proc)
- shifts = 2 if get_separation_quality() == "best" else 1
+ # The quality separate() recorded for this job (#804), else the setting.
+ quality = job.separation_quality or get_separation_quality()
+ shifts = 2 if quality == "best" else 1
req = json.dumps({"source": str(source), "job_dir": str(job_dir), "shifts": shifts}) + "\n"
try:
proc.stdin.write(req)
@@ -131,6 +291,13 @@ def _run_demucs(job: Job, source: Path, job_dir: Path, device: str) -> tuple[int
# separate()'s retry policy handles it exactly like a nonzero exit.
set_proc(job.id, None)
_kill_worker()
+ # Unless it died because this job was cancelled: set_proc() stops a
+ # worker registered after its job's cancel (#748), and on Linux that
+ # stop lands before this write, which then meets a closed pipe. That
+ # is a cancel, not a failed separation (Windows still buffers the
+ # write, so there the read below saw EOF and said so).
+ if job.cancel_requested:
+ raise JobCancelled() from None
return 1, ["demucs worker is not accepting input (died before dispatch)"]
# Time from dispatch to the first progress line -- near-zero for a reused
@@ -146,20 +313,34 @@ def _run_demucs(job: Job, source: Path, job_dir: Path, device: str) -> tuple[int
tail: list[str] = []
last_output: list[float] = [time.monotonic()]
job_ok: bool | None = None # None while streaming; True/False once decided
+ # The model file the worker is downloading (#125): torch.hub's
+ # "Downloading:" line, until that file's 100% or @@READY@@. Only this
+ # phase has the short stall limit; importing torch and loading a cached
+ # model say nothing either, and are not held to it. A reused worker loaded
+ # its model on an earlier job, so it never downloads.
+ fetching: re.Match[str] | None = None
+ # Set by the watchdog when it stops a worker that stalled downloading.
+ stalled_fetching = threading.Event()
# Event set by the reader loop when the job finishes 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:
+ while True:
+ limit = TIMEOUT_DEMUCS_STALL if fetching is None else TIMEOUT_MODEL_STALL
+ # Woken often enough for the short limit, which a download line
+ # can switch on between two checks.
+ if _done_evt.wait(timeout=min(30, TIMEOUT_MODEL_STALL)) or proc.poll() is not None:
return
- if time.monotonic() - last_output[0] > TIMEOUT_DEMUCS_STALL:
+ if time.monotonic() - last_output[0] > limit:
logger.warning(
- "demucs worker stalled for %ss with no output, terminating job %s",
- TIMEOUT_DEMUCS_STALL,
+ "demucs worker stalled for %ss with no output (%s), terminating job %s",
+ limit,
+ "separating" if fetching is None else "downloading the model",
job.id,
)
+ if fetching is not None:
+ stalled_fetching.set()
proc.terminate()
return
@@ -167,7 +348,13 @@ def _watchdog() -> None:
wt.start()
try:
while True:
- ch = proc.stderr.read(1)
+ try:
+ ch = proc.stderr.read(1)
+ except ValueError as exc:
+ # The pipe was closed under the read: a shutdown or another
+ # stop ran _kill_worker. The same as the worker exiting.
+ logger.warning("[%s] demucs worker stderr closed during read: %s", job.id, exc)
+ ch = ""
if not ch:
# EOF -- the worker process itself exited (crash, or it
# already wrote @@ERROR@@ and is shutting down).
@@ -179,6 +366,9 @@ def _watchdog() -> None:
buf = ""
if not line:
continue
+ if line == "@@READY@@":
+ fetching = None
+ continue
if line == "@@DONE@@":
job_ok = True
break
@@ -192,17 +382,24 @@ def _watchdog() -> None:
job_ok = False
break
m = _PCT_RE.search(line)
- if m:
+ if m and fetching is not None:
+ # The model download's progress bar, not separation.
+ pct = max(0, min(100, int(m.group(1))))
+ _set(job, stage=f"Downloading the separation model {pct}%")
+ if pct == 100:
+ fetching = None
+ elif m:
if not startup_recorded:
startup_recorded = True
- if job.stage_timings is None:
- job.stage_timings = {}
- job.stage_timings["separate_startup"] = round(
- time.monotonic() - spawn_at, 1
- )
+ # A new dict, as runner._lap writes one.
+ job.stage_timings = {
+ **(job.stage_timings or {}),
+ "separate_startup": round(time.monotonic() - spawn_at, 1),
+ }
pct = max(0, min(100, int(m.group(1))))
_set(job, progress=pct / 100.0, stage=f"Separating {pct}%")
else:
+ fetching = _DOWNLOADING_RE.match(line) or fetching
tail.append(line)
if len(tail) > 40:
tail.pop(0)
@@ -230,10 +427,17 @@ def _watchdog() -> None:
# generic "demucs failed" path.
if job.cancel_requested:
raise JobCancelled()
+ if stalled_fetching.is_set() and fetching is not None:
+ # Said in words, or the job fails as a bare "exit status 1".
+ tail.append(
+ f"model download failed: {fetching['url']};"
+ f" put the file in {os.path.dirname(fetching['dst'])}"
+ f" (no data for {TIMEOUT_MODEL_STALL} s)"
+ )
return (0, tail) if job_ok else (1, tail)
-def separate(job: Job, source: Path, job_dir: Path) -> Path:
+def separate(job: Job, source: Path, job_dir: Path, quality: str | None = None) -> Path:
"""Run demucs on the configured device, falling back to CPU once when a
GPU attempt fails (#276).
@@ -242,8 +446,24 @@ def separate(job: Job, source: Path, job_dir: Path) -> Path:
tail, and gpu_fallback/compute_device persist to job state and metadata.
It applies even when the user forced cuda/mps in Settings -- a dead job
with no diagnostics is strictly worse for them than a slow one that
- explains itself."""
+ explains itself.
+
+ `quality` is the Separation quality to use, "standard" or "best"; None
+ reads the setting, per job like the device. Recorded on the job with the
+ model (#804), so stems separated later for the same song (fill-stems) use
+ the same settings and add up with the ones it has.
+
+ Holds the worker in use for the whole call, the CPU retry included, so the
+ idle timer never stops it under a job (#975)."""
+ with WORKER.in_use():
+ return _separate(job, source, job_dir, quality)
+
+
+def _separate(job: Job, source: Path, job_dir: Path, quality: str | None) -> Path:
_set(job, status="separating", progress=0.0, stage="Separating stems...")
+ quality = quality or get_separation_quality()
+ job.separation_model = DEMUCS_MODEL
+ job.separation_quality = quality
# Read the device fresh per job (not a frozen import) so a Settings change
# applies to the next separation without a restart. Recorded on the job for
@@ -253,9 +473,12 @@ def separate(job: Job, source: Path, job_dir: Path) -> Path:
logger.info("[%s] separating on device=%s", job.id, device)
rc, tail = _run_demucs(job, source, job_dir, device)
+ cause = classify_failure("\n".join(tail)) if rc != 0 else ""
- if rc != 0 and device != "cpu":
- cause = classify_failure("\n".join(tail))
+ # A worker the shutdown killed is not a GPU failure: a CPU pass started
+ # now would keep the backend from exiting. Nor is a model that could not be
+ # downloaded: the CPU worker fetches the same file and fails the same way.
+ if rc != 0 and device != "cpu" and not jobqueue.is_stopping() and cause != "model-download":
logger.warning(
"[%s] demucs failed on %s (exit %s, cause=%s); retrying on CPU. tail:\n%s",
job.id,
diff --git a/app/pipeline/stem_fill.py b/app/pipeline/stem_fill.py
new file mode 100644
index 00000000..3daef6d7
--- /dev/null
+++ b/app/pipeline/stem_fill.py
@@ -0,0 +1,182 @@
+"""Separate only the stems a song does not have yet (#804).
+
+Extract on a song that is already in the library never separates it over
+again: the stems it has are kept as they are, byte for byte, and only a stem it
+is missing is made, with the same model and quality the song was separated
+with, so the new stem adds up with the old ones to the original. A stem that
+appears on disk while this runs is never replaced either.
+
+The run works on a stand-in Job with the song's id, so the song itself stays
+"done" with its diagnostics (device, timings, progress) untouched while it
+plays; only its stem list, presence and peaks gain the new stems. Everything
+the separation writes goes into a scratch folder inside the song's folder,
+removed afterwards whatever happens.
+
+Blocking: the caller runs it with asyncio.to_thread under _pipeline_lock.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import threading
+import uuid
+from pathlib import Path
+
+from app.core.config import DEMUCS_MODEL, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable
+from app.core.models import Job
+from app.core.stem_files import stem_exists, stem_url
+from app.pipeline.cancel import check_cancel, run_registered
+from app.pipeline.collect import merge_stem_peaks, presence_for_split
+from app.pipeline.download import download
+from app.pipeline.separate import separate
+from app.pipeline.stem_storage import store_split_stems
+
+logger = logging.getLogger("stemdeck.stem_fill")
+
+# The quality a song separated before it was recorded is filled with: the
+# default, which is what every song had before "best" was offered.
+FALLBACK_QUALITY = "standard"
+
+# The stand-in of each fill running now, by song id: its stages read the
+# stand-in's flag, not the song's, so this is how POST .../cancel reaches them.
+_STAND_INS: dict[str, Job] = {}
+_STAND_INS_LOCK = threading.Lock()
+
+
+class FillRefused(Exception):
+ """The song cannot be filled as asked. `code` is a fixed word the API
+ turns into a safe message: "no_source", "model"."""
+
+ def __init__(self, code: str) -> None:
+ super().__init__(code)
+ self.code = code
+
+
+def cancel_fill(job_id: str) -> bool:
+ """Ask the running fill of `job_id` to stop. True when one was running."""
+ with _STAND_INS_LOCK:
+ stand_in = _STAND_INS.get(job_id)
+ if stand_in is None:
+ return False
+ stand_in.cancel_requested = True
+ return True
+
+
+def missing_stems(stems_dir: Path, wanted: list[str]) -> list[str]:
+ """The base stems in `wanted` with no file in `stems_dir`, in order."""
+ return [n for n in STEM_NAMES if n in wanted and not stem_exists(stems_dir, n)]
+
+
+def _as_wav(stand_in: Job, source: Path, work: Path) -> Path:
+ """`source` itself when it is a WAV, else a 44.1 kHz 16-bit WAV of it in
+ `work`. The song's own source is only read, never moved or deleted."""
+ if source.suffix.lower() == ".wav":
+ return source
+ dest = work / "source.wav"
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(source),
+ "-ar",
+ "44100",
+ "-ac",
+ "2",
+ "-sample_fmt",
+ "s16",
+ "-y",
+ str(dest),
+ ]
+ result = run_registered(stand_in, cmd, TIMEOUT_FFMPEG)
+ if result.returncode != 0 or not dest.is_file():
+ raise RuntimeError("could not prepare the source for separation")
+ return dest
+
+
+def _place(src: Path, dest: Path) -> bool:
+ """Move a new stem into place unless a file is already there. The move is
+ a rename inside the song's folder, so the stem appears whole or not at
+ all. Returns whether it was placed."""
+ if not src.is_file() or dest.exists():
+ return False
+ tmp = dest.with_name(f".{dest.stem}.{uuid.uuid4().hex}.fill.wav")
+ try:
+ shutil.move(str(src), tmp)
+ if dest.exists():
+ return False
+ os.replace(tmp, dest)
+ return True
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def fill_stems(job: Job, job_dir: Path, wanted: list[str], source: Path | None) -> list[str]:
+ """Separate the stems of `wanted` the song has no file for and add them.
+
+ `source`: the upload kept beside the song, or None for a link, which is
+ downloaded again. Returns the names added (empty when none was missing).
+ Raises FillRefused when the song cannot be filled the way it was
+ separated, and any separation error as it is."""
+ stems_dir = job_dir / "stems"
+ missing = missing_stems(stems_dir, wanted)
+ if not missing:
+ return []
+ model = job.separation_model or DEMUCS_MODEL
+ if model != DEMUCS_MODEL:
+ # A different model's stems would not add up with these.
+ raise FillRefused("model")
+ url = None if source is not None else (job.source_url or "")
+ if source is None and (not url or url.startswith("local:")):
+ raise FillRefused("no_source")
+
+ with _STAND_INS_LOCK:
+ # A cancel sent while the fill waited for the pipeline is on the song.
+ stand_in = Job(
+ id=job.id,
+ source_url=job.source_url,
+ title=job.title,
+ cancel_requested=job.cancel_requested,
+ )
+ _STAND_INS[job.id] = stand_in
+ work = job_dir / f".fill-{uuid.uuid4().hex[:8]}"
+ added: list[str] = []
+ try:
+ check_cancel(stand_in)
+ work.mkdir()
+ src = download(stand_in, url, work) if source is None else source
+ src = _as_wav(stand_in, src, work)
+ # The separation only looks at the flag once it is over.
+ check_cancel(stand_in)
+ quality = job.separation_quality or FALLBACK_QUALITY
+ stems_root = separate(stand_in, src, work, quality=quality)
+ for name in missing:
+ if _place(stems_root / f"{name}.wav", stems_dir / f"{name}.wav"):
+ added.append(name)
+ finally:
+ with _STAND_INS_LOCK:
+ _STAND_INS.pop(job.id, None)
+ shutil.rmtree(work, ignore_errors=True)
+ if not added:
+ return []
+
+ # The peaks are read from the new WAVs first, with one stem whose presence
+ # is known so the new ones land on the same scale; then they are kept the
+ # way the song keeps its stems (#783). Neither raises.
+ reference = next((n for n in (job.stem_presence or {}) if stem_exists(stems_dir, n)), None)
+ scan = [*([reference] if reference and reference not in added else []), *added]
+ rms_values = merge_stem_peaks(stems_dir, scan)
+ store_split_stems(job, stems_dir, added)
+ if reference:
+ extra = presence_for_split(rms_values, job.stem_presence, reference=reference)
+ if extra:
+ job.stem_presence = {**(job.stem_presence or {}), **extra}
+ known = {s.get("name") for s in job.stems}
+ for name in added:
+ if name not in known:
+ job.stems.append({"name": name, "url": stem_url(job.id, stems_dir, name)})
+ logger.info("[%s] filled missing stems: %s", job.id, ", ".join(added))
+ return added
diff --git a/app/pipeline/stem_storage.py b/app/pipeline/stem_storage.py
new file mode 100644
index 00000000..9032ef00
--- /dev/null
+++ b/app/pipeline/stem_storage.py
@@ -0,0 +1,502 @@
+"""The last pipeline stage: keep a finished song's stems as WAV or MP3 (#783).
+
+Separation and every analysis stage run on WAV exactly as they always did.
+Only once the song has nothing left to read from its stems does this stage
+look at the Stem storage format setting (read now, so a change applies to the
+next song without a restart):
+
+- WAV: nothing is touched. The job records "wav".
+- MP3: every stem file in stems/ (the six stems, original, mix) is encoded by
+ ffmpeg's libmp3lame at a constant bitrate into a temporary file, and each
+ one is verified: ffmpeg and libsndfile, the two decoders the server reads
+ stems with, must both decode it to exactly the WAV's frame count, and the
+ start of the decoded audio must line up with the WAV's. Only when every stem
+ passed are the MP3s moved into place (rename, so never half-written) and,
+ once they and the folder are on the disk (fsync), the WAVs deleted.
+
+Any failure (ffmpeg without libmp3lame, a full disk, a stall, an output that
+does not decode or does not line up) removes the temporary files, keeps every
+WAV and records the song as "wav". The song finishes either way: this stage
+never fails a job. A cancel raises JobCancelled, after the same cleanup.
+
+Sample accuracy: libmp3lame adds an encoder delay and padding. ffmpeg writes
+both into the LAME/Xing header of the first frame (-write_xing 1, its
+default, made explicit below), and ffmpeg, libsndfile (mpg123) and Chromium's
+decodeAudioData all trim by it, so a decoded MP3 starts on the same sample as
+the WAV and has the same length. tests/test_stem_storage.py measures it.
+
+`original.wav` here is the complement backing track (make_original_track),
+a stem like the others, so it is stored the same way. The song's own source is
+not in stems/ and is never touched: an upload's kept source. stays as it
+was uploaded.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import subprocess
+import threading
+import time
+import uuid
+from pathlib import Path
+
+from app.core.config import (
+ EXTRA_STEM_NAMES,
+ STEM_NAMES,
+ TIMEOUT_FFMPEG,
+ TIMEOUT_STEM_TRANSCODE_STALL,
+ ffmpeg_executable,
+)
+from app.core.models import Job, JobCancelled, _set
+from app.core.registry import set_proc
+from app.core.settings import fsync_dir, get_stem_format, get_stem_mp3_kbps
+from app.core.stem_files import stem_url
+
+logger = logging.getLogger("stemdeck.pipeline")
+
+# Every stem file a finished song can hold.
+STORED_NAMES: tuple[str, ...] = (*STEM_NAMES, *EXTRA_STEM_NAMES, "original", "mix")
+# The alignment check compares the loudest block of this many seconds of each
+# stem, found in one sequential pass over both files (no seeking, constant
+# memory). After the exact length check it catches an output that decodes to
+# the right length but not to the right place.
+_ALIGN_BLOCK_SEC = 10
+# A block quieter than this RMS is near silence: a stem that never plays has
+# nothing to line up, so only its length is checked.
+_ALIGN_MIN_RMS = 0.01
+# The lags searched either side of 0, in samples: past the largest offset a
+# decoder that ignored the LAME header would add (1105 = 576 encoder delay +
+# 529 decoder delay at 44.1 kHz).
+_ALIGN_MAX_LAG = 4096
+# A correct encode correlates at about 0.99 at lag 0 even at 128 kbps.
+# Refused: a block whose lag-0 correlation is under the floor (not this audio),
+# or clearly below the best one within the searched lags (shifted). The ratio
+# leaves room for steady tones, whose correlation repeats every period.
+_ALIGN_MIN_CORR = 0.5
+_ALIGN_MIN_RATIO = 0.98
+# The encoder's output is shown to nobody. Kept for the log line on failure.
+_TAIL_LINES = 15
+
+
+class StemStorageError(RuntimeError):
+ """The MP3s could not be made or did not verify. The WAVs stay."""
+
+
+def _encode_cmd(src: Path, dest: Path, kbps: int) -> list[str]:
+ """One stem to CBR MP3. Module-level seam: tests swap in a stub encoder.
+
+ `-progress pipe:1` prints a status block about once a second, which is
+ what the stall watchdog listens to."""
+ return [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-nostats",
+ "-progress",
+ "pipe:1",
+ "-i",
+ str(src),
+ "-map",
+ "0:a:0",
+ "-c:a",
+ "libmp3lame",
+ "-b:a",
+ f"{kbps}k",
+ # The LAME header carries the encoder delay and padding, which is
+ # what makes the decoded MP3 sample-aligned with the WAV.
+ "-write_xing",
+ "1",
+ "-f",
+ "mp3",
+ "-y",
+ str(dest),
+ ]
+
+
+def _encode(job: Job, src: Path, dest: Path, kbps: int) -> None:
+ """Run the encoder for one stem, registered for cancel, under a stall
+ watchdog. Raises JobCancelled or StemStorageError."""
+ try:
+ proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ _encode_cmd(src, dest, kbps),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ except OSError as exc:
+ raise StemStorageError("the MP3 encoder could not be started") from exc
+ set_proc(job.id, proc)
+ last_output = [time.monotonic()]
+ stalled = threading.Event()
+ done = threading.Event()
+ err_tail: list[str] = []
+
+ def _watchdog() -> None:
+ while not done.wait(timeout=0.2):
+ if job.cancel_requested:
+ proc.terminate()
+ return
+ if time.monotonic() - last_output[0] > TIMEOUT_STEM_TRANSCODE_STALL:
+ logger.warning(
+ "[%s] stem encode silent for %ss; stopping it",
+ job.id,
+ TIMEOUT_STEM_TRANSCODE_STALL,
+ )
+ stalled.set()
+ proc.terminate()
+ return
+
+ def _drain_stderr() -> None:
+ assert proc.stderr is not None
+ for line in proc.stderr:
+ err_tail.append(line.rstrip())
+ del err_tail[:-_TAIL_LINES]
+
+ watch = threading.Thread(target=_watchdog, daemon=True)
+ drain = threading.Thread(target=_drain_stderr, daemon=True)
+ watch.start()
+ drain.start()
+ try:
+ assert proc.stdout is not None
+ for _line in proc.stdout:
+ last_output[0] = time.monotonic()
+ try:
+ proc.wait(timeout=TIMEOUT_FFMPEG)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.communicate()
+ raise StemStorageError("the MP3 encoder did not exit") from None
+ finally:
+ done.set()
+ set_proc(job.id, None)
+ watch.join(timeout=2)
+ drain.join(timeout=2)
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.communicate()
+ if job.cancel_requested:
+ raise JobCancelled()
+ if stalled.is_set():
+ raise StemStorageError("the MP3 encoder stalled")
+ if proc.returncode != 0:
+ detail = " | ".join(err_tail[-3:]) or f"exit {proc.returncode}"
+ raise StemStorageError(f"the MP3 encoder failed: {detail}")
+ if not dest.is_file() or dest.stat().st_size == 0:
+ raise StemStorageError("the MP3 encoder wrote nothing")
+
+
+def _ffmpeg_frames(job: Job, path: Path, channels: int) -> int:
+ """Frames ffmpeg decodes `path` to: the whole file, counted as it streams."""
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(path),
+ "-map",
+ "0:a:0",
+ "-ac",
+ str(channels),
+ "-c:a",
+ "pcm_s16le",
+ "-f",
+ "s16le",
+ "-",
+ ]
+ proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
+ )
+ set_proc(job.id, proc)
+ total = 0
+ try:
+ assert proc.stdout is not None
+ while chunk := proc.stdout.read(1 << 20):
+ total += len(chunk)
+ proc.wait(timeout=TIMEOUT_FFMPEG)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.communicate()
+ raise StemStorageError("the MP3 check did not finish") from None
+ finally:
+ set_proc(job.id, None)
+ if job.cancel_requested:
+ raise JobCancelled()
+ if proc.returncode != 0:
+ raise StemStorageError("the MP3 does not decode")
+ return total // (2 * channels)
+
+
+def verify_mp3(job: Job, wav: Path, mp3: Path) -> None:
+ """Raise StemStorageError unless `mp3` decodes to `wav`'s exact length in
+ both ffmpeg and libsndfile, at its rate and channel count, and its
+ loudest block lines up with the WAV's (cross-correlation peak at lag 0)."""
+ import numpy as np
+ import soundfile as sf
+
+ try:
+ ref = sf.info(str(wav))
+ got = sf.info(str(mp3))
+ except Exception as exc: # libsndfile raises its own types
+ raise StemStorageError("the MP3 cannot be read by libsndfile") from exc
+ if (got.samplerate, got.channels) != (ref.samplerate, ref.channels):
+ raise StemStorageError("the MP3 has another rate or channel count")
+ if got.frames != ref.frames:
+ raise StemStorageError(f"libsndfile decodes {got.frames} frames, the WAV has {ref.frames}")
+ ff = _ffmpeg_frames(job, mp3, ref.channels)
+ if ff != ref.frames:
+ raise StemStorageError(f"ffmpeg decodes {ff} frames, the WAV has {ref.frames}")
+ try:
+ a, b = _loudest_block(wav, mp3, ref.samplerate * _ALIGN_BLOCK_SEC)
+ except Exception as exc:
+ raise StemStorageError("the MP3 cannot be read back") from exc
+ if a is None or float(np.sqrt(np.mean(a * a))) < _ALIGN_MIN_RMS:
+ return
+ zero, best, lag = _correlation(a, b, _ALIGN_MAX_LAG)
+ if zero < _ALIGN_MIN_CORR or zero < _ALIGN_MIN_RATIO * best:
+ raise StemStorageError(
+ f"the MP3 does not line up with the WAV (lag {lag}, {zero:.3f} at 0 against {best:.3f})"
+ )
+
+
+def _loudest_block(wav: Path, mp3: Path, size: int):
+ """Channel 0 of the loudest `size`-frame block of `wav`, and the same
+ block of `mp3`, read side by side from the start. (None, None) for an
+ empty file."""
+ import numpy as np
+ import soundfile as sf
+
+ best_rms, best = -1.0, (None, None)
+ pairs = zip(
+ sf.blocks(str(wav), blocksize=size, dtype="float32", always_2d=True),
+ sf.blocks(str(mp3), blocksize=size, dtype="float32", always_2d=True),
+ strict=True,
+ )
+ for a, b in pairs:
+ if len(a) != len(b):
+ raise StemStorageError("the MP3 reads back at another length")
+ a0 = a[:, 0].astype(np.float64)
+ rms = float(np.sqrt(np.mean(a0 * a0))) if a0.size else 0.0
+ if rms > best_rms:
+ best_rms, best = rms, (a0, b[:, 0].astype(np.float64))
+ return best
+
+
+def _correlation(a, b, max_lag: int) -> tuple[float, float, int]:
+ """Normalised cross-correlation of b against a: its value at lag 0, its
+ best value within +-max_lag, and that lag."""
+ import numpy as np
+
+ n = len(a)
+ nfft = 1 << (2 * n).bit_length()
+ c = np.fft.irfft(np.fft.rfft(b, nfft) * np.conj(np.fft.rfft(a, nfft)), nfft)
+ denom = float(np.sqrt(np.sum(a * a) * np.sum(b * b)))
+ if denom <= 0:
+ return 0.0, 0.0, 0
+ k = min(max_lag, n - 1)
+ window = np.concatenate([c[-k:], c[: k + 1]]) / denom if k else c[:1] / denom
+ i = int(np.argmax(window))
+ return float(c[0] / denom), float(window[i]), i - k
+
+
+def _stamp(path: Path) -> list[int] | None:
+ try:
+ st = path.stat()
+ except OSError:
+ return None
+ return [st.st_size, st.st_mtime_ns]
+
+
+def _keep_derived_files(job_dir: Path, vocals_wav_stamp: list[int] | None) -> None:
+ """Files kept beside the stems that are checked against the vocals stem's
+ size and time: the same audio is now in another file, so they stay valid
+ and are not recomputed. Best effort."""
+ stems_dir = job_dir / "stems"
+ vocals_mp3 = stems_dir / "vocals.mp3"
+ if vocals_wav_stamp is None or not vocals_mp3.is_file():
+ return
+ try:
+ from app.pipeline.lyrics_retime import restamp_transcript
+
+ restamp_transcript(job_dir, vocals_wav_stamp)
+ except Exception:
+ logger.info("[%s] could not keep the transcript's stamp", job_dir.name, exc_info=True)
+ envelope = stems_dir / "vocal_envelope.json"
+ try:
+ # Kept while it is not older than the stem (lyrics_align._envelope,
+ # GET /vocal-envelope): it was made from the WAV, and still holds.
+ if envelope.is_file() and envelope.stat().st_mtime_ns >= vocals_wav_stamp[1]:
+ os.utime(envelope)
+ except OSError:
+ logger.info("[%s] could not keep the vocal envelope", job_dir.name, exc_info=True)
+
+
+def _record(job: Job, fmt: str, kbps: int | None) -> None:
+ _set(job, stem_format=fmt, stem_mp3_kbps=kbps)
+
+
+def _refresh_urls(job: Job, stems_dir: Path) -> None:
+ """Point the job's stem URLs (and the mix link, when it is a stem file) at
+ the files now on disk."""
+ stems = []
+ for entry in job.stems:
+ name = entry.get("name", "")
+ stems.append({**entry, "url": stem_url(job.id, stems_dir, name)} if name else entry)
+ prefix = f"/api/jobs/{job.id}/stems/"
+ mix_url = job.mix_url
+ if mix_url and mix_url.startswith(prefix):
+ name = Path(mix_url[len(prefix) :]).stem
+ if name in STORED_NAMES:
+ mix_url = stem_url(job.id, stems_dir, name)
+ _set(job, stems=stems, mix_url=mix_url)
+
+
+def store_stems(job: Job, job_dir: Path) -> None:
+ """Keep the song's stems in the format the setting asks for now. Never
+ raises but for a cancel; see the module docstring."""
+ if job.cancel_requested:
+ raise JobCancelled()
+ fmt = get_stem_format()
+ if fmt != "mp3":
+ _record(job, "wav", None)
+ return
+ kbps = get_stem_mp3_kbps()
+ stems_dir = job_dir / "stems"
+ wavs = [p for p in (stems_dir / f"{n}.wav" for n in STORED_NAMES) if p.is_file()]
+ if not wavs:
+ _record(job, "wav", None)
+ return
+
+ _set(job, stage="Compressing stems...")
+ started = time.monotonic()
+ made: list[tuple[Path, Path]] = []
+ try:
+ for wav in wavs:
+ if job.cancel_requested:
+ raise JobCancelled()
+ # Dot-prefixed and .tmp-marked: no reader takes it for a stem.
+ tmp = stems_dir / f".{wav.stem}.{uuid.uuid4().hex}.tmp.mp3"
+ made.append((wav, tmp))
+ _encode(job, wav, tmp, kbps)
+ verify_mp3(job, wav, tmp)
+ _fsync(tmp)
+ except JobCancelled:
+ _discard(made)
+ raise
+ except Exception as exc:
+ # StemStorageError, or anything unexpected (OSError on a full disk,
+ # libsndfile): the WAVs are all still here, so the song is a WAV song.
+ _discard(made)
+ logger.warning("[%s] stems kept as WAV: MP3 storage failed: %s", job.id, exc)
+ _record(job, "wav", None)
+ return
+
+ vocals_stamp = _stamp(stems_dir / "vocals.wav")
+ moved: list[Path] = []
+ try:
+ for wav, tmp in made:
+ final = wav.with_suffix(".mp3")
+ os.replace(tmp, final)
+ moved.append(final)
+ except OSError as exc:
+ # Every WAV is still here and wins over an MP3 of the same name
+ # (stem_files.stem_path); the moved ones are removed so nothing
+ # half-done is left behind.
+ _discard(made)
+ for final in moved:
+ final.unlink(missing_ok=True)
+ logger.warning("[%s] stems kept as WAV: could not move the MP3s: %s", job.id, exc)
+ _record(job, "wav", None)
+ return
+
+ fsync_dir(stems_dir)
+ before = sum(_size(w) for w in wavs)
+ after = sum(_size(w.with_suffix(".mp3")) for w in wavs)
+ for wav in wavs:
+ try:
+ wav.unlink()
+ except OSError:
+ # Harmless: a WAV left behind is still a valid stem and wins over
+ # its MP3. Logged, so the disk it costs is not a mystery.
+ logger.warning("[%s] could not delete %s after storing it as MP3", job.id, wav.name)
+ _keep_derived_files(job_dir, vocals_stamp)
+ _refresh_urls(job, stems_dir)
+ _record(job, "mp3", kbps)
+ logger.info(
+ "[%s] stems stored as MP3 %dk: %d files, %.1f MB -> %.1f MB in %.1fs",
+ job.id,
+ kbps,
+ len(wavs),
+ before / 1e6,
+ after / 1e6,
+ time.monotonic() - started,
+ )
+
+
+def _size(path: Path) -> int:
+ try:
+ return path.stat().st_size
+ except OSError:
+ return 0
+
+
+def _fsync(path: Path) -> None:
+ """Put the MP3's bytes on the disk before its WAV is deleted. A rename can
+ reach the disk before the data does, and a power cut then leaves an empty
+ MP3 with the WAV gone. Opened for writing because Windows only flushes a
+ handle that can write."""
+ with open(path, "r+b") as fh:
+ os.fsync(fh.fileno())
+
+
+def _discard(made: list[tuple[Path, Path]]) -> None:
+ for _wav, tmp in made:
+ try:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ logger.debug("could not remove %s", tmp, exc_info=True)
+
+
+def store_split_stems(job: Job, stems_dir: Path, names: list[str]) -> None:
+ """The on-demand lead/backing split writes WAVs into a song that may be
+ stored as MP3. They are stored the way the song is, with the same
+ verification, and stay WAV on any failure. Never raises."""
+ if job.stem_format != "mp3" or not job.stem_mp3_kbps:
+ return
+ made: list[tuple[Path, Path]] = []
+ try:
+ for name in names:
+ wav = stems_dir / f"{name}.wav"
+ if not wav.is_file():
+ continue
+ tmp = stems_dir / f".{name}.{uuid.uuid4().hex}.tmp.mp3"
+ made.append((wav, tmp))
+ _encode(job, wav, tmp, int(job.stem_mp3_kbps))
+ verify_mp3(job, wav, tmp)
+ _fsync(tmp)
+ for wav, tmp in made:
+ os.replace(tmp, wav.with_suffix(".mp3"))
+ except Exception as exc:
+ _discard(made)
+ for wav, _tmp in made:
+ if wav.is_file():
+ # Never both for long: the WAV wins anyway, the MP3 is clutter.
+ wav.with_suffix(".mp3").unlink(missing_ok=True)
+ logger.warning("[%s] split stems kept as WAV: %s", job.id, exc)
+ return
+ fsync_dir(stems_dir)
+ for wav, _tmp in made:
+ try:
+ wav.unlink()
+ except OSError:
+ logger.warning("[%s] could not delete %s after storing it as MP3", job.id, wav.name)
diff --git a/app/pipeline/take_render.py b/app/pipeline/take_render.py
new file mode 100644
index 00000000..575e3ec7
--- /dev/null
+++ b/app/pipeline/take_render.py
@@ -0,0 +1,637 @@
+"""ffmpeg work for recorded takes: the playable audio and the video render.
+
+Everything here is blocking and runs in a thread (asyncio.to_thread) from
+app/api/takes.py. The command builders are pure functions so the filter
+graphs can be tested without running ffmpeg.
+
+Sign convention, shared with the capture layer in the browser: a positive
+latency or offset means the material arrived late, so it is subtracted.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import shutil
+import struct
+import subprocess
+import threading
+import time
+import uuid
+from collections import deque
+from collections.abc import Callable
+from pathlib import Path
+from typing import NamedTuple
+
+import soundfile as sf
+
+from app.core.config import (
+ CANVAS_SHORT_SIDES,
+ EXPORT_LIMIT_DBFS,
+ EXPORT_LIMIT_LOSSY_DBTP,
+ TIMEOUT_FFMPEG,
+ VIDEO_CPU_ENCODER,
+ WAV_PLAIN_HEADER_ARGS,
+ ffmpeg_executable,
+)
+from app.core.registry import job_dirs
+from app.pipeline import video_encoder
+
+logger = logging.getLogger("stemdeck.takes")
+
+# The stems' own format (app/pipeline/collect.py writes pcm_s16le; Demucs
+# output is 44.1 kHz stereo). A take in exactly this format needs no
+# take-specific code in the chunked engine, the peaks, the mixdown or the zip.
+TAKE_RATE = 44100
+TAKE_CHANNELS = 2
+
+# alimiter's limit is linear. level=disabled stops it re-normalising the
+# output to 0 dB (its default does, measured -12.8 instead of -14 LUFS in the
+# mastering study) and latency=1 removes its look-ahead delay, so a lane
+# keeps its timing to the sample. Measured on the bundled ffmpeg 8.1: with a
+# signal under the limit the output is bit-identical to the input, same
+# length; two 0.7 sines summed (52152 samples at full scale without it) peak
+# at exactly -1.0 dBFS with none at full scale. This one is for lossless
+# exports (WAV, FLAC), where bit-identical below the ceiling matters.
+EXPORT_LIMITER = f"alimiter=limit={10 ** (EXPORT_LIMIT_DBFS / 20):.6f}:level=disabled:latency=1"
+
+# Lossy exports (MP3, OGG, AAC) limit the true peak instead: alimiter at the
+# stem rate only sees the samples, and the peaks between them (up to 0.5 dB
+# over, measured) plus what the encoder adds reached -0.4 dBTP after
+# decoding (MP3, OGG) and +0.4 dBFS with clipped samples (AAC). At 4x the samples are the true peak (the ffmpeg docs recommend
+# 2x to 4x before alimiter), and the lower ceiling leaves the encoder room.
+# The round trip through 176.4 kHz is not bit-identical, which only matters
+# for a lossless file: a lossy encoder rewrites every sample anyway. Cost:
+# 3.7 s per 10 minutes of song, measured.
+EXPORT_LIMITER_LOSSY = (
+ f"aresample={4 * TAKE_RATE},"
+ f"alimiter=limit={10 ** (EXPORT_LIMIT_LOSSY_DBTP / 20):.6f}:level=disabled:latency=1,"
+ f"aresample={TAKE_RATE}"
+)
+
+# AAC as every MP4 export writes it. Intensity stereo is off: with it on (the
+# encoder's default) the native encoder sent single blocks of a song up to
+# 2.7 dB over its input, so a mix limited to -2 dBTP decoded at +0.7 dBFS.
+# Without it the encoder stayed within 0.5 dB of its input on every song
+# measured.
+# A low-bitrate tool; at 192 kbps it saves nothing worth a click.
+AAC_ENCODE_ARGS = ["-c:a", "aac", "-b:a", "192k", "-aac_is", "0"]
+# The fallback for a mastered video whose AAC still decoded over its ceiling
+# (stems._render_master_verified): the default "twoloop" coder sometimes
+# sends a single block several dB over its input even without intensity
+# stereo (measured +3.6 dBFS on a hot song limited at -1.6 dB, at a level a
+# lower input did not fix: not monotonic). The "fast" coder stayed within
+# 0.8 dB of its input at every level tried on four mastered songs. It is the
+# fallback, not the default, because it is the encoder's simpler rate control
+# (sound not compared by ear).
+AAC_ENCODE_ARGS_ALT = [*AAC_ENCODE_ARGS, "-aac_coder", "fast"]
+
+
+def export_limiter(lossy: bool) -> str:
+ """The limiter that ends an export's audio graph, for its encoder."""
+ return EXPORT_LIMITER_LOSSY if lossy else EXPORT_LIMITER
+
+
+class TakeRenderError(RuntimeError):
+ """ffmpeg failed. The message is for the log, never for the client.
+
+ `code` says how, for the GPU fallback's reason: "exit" (ffmpeg failed),
+ "timeout", "stall" (no progress), or "invalid-" (the file failed
+ video_encoder.validate_output). `stderr` is ffmpeg's last lines, for
+ video_encoder.blames_encoder (#791)."""
+
+ def __init__(self, message: str, code: str = "exit", stderr: str = "") -> None:
+ super().__init__(message)
+ self.code = code
+ self.stderr = stderr
+
+
+def write_float_wav_header(fh, data_bytes: int, rate: int, channels: int) -> None:
+ """A WAVE_FORMAT_IEEE_FLOAT (3) header for 32-bit interleaved samples.
+
+ The stdlib wave module writes integer PCM only, and the raw capture is
+ float32: kept bit-exact so a later re-render (a nudge, an import on
+ another machine) starts from what the microphone delivered."""
+ block_align = channels * 4
+ fh.write(b"RIFF")
+ fh.write(struct.pack(" None:
+ """Wrap the uploaded float32 parts in a WAV header, atomically."""
+ size = pcm.stat().st_size
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ with tmp.open("wb") as out, pcm.open("rb") as src:
+ write_float_wav_header(out, size, rate, channels)
+ shutil.copyfileobj(src, out, 1024 * 1024)
+ os.replace(tmp, dest)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def wav_frames(path: Path) -> int:
+ return int(sf.info(str(path)).frames)
+
+
+def sniff_container(path: Path) -> str | None:
+ """ "webm" or "mp4" from the first bytes, or None.
+
+ The browser picks the container (WebM on Chromium and Firefox, MP4 on
+ Safari/WKWebView), and a declared MIME type is only a claim. The file
+ name decides which demuxer the render trusts, so it comes from the bytes."""
+ try:
+ with path.open("rb") as fh:
+ head = fh.read(12)
+ except OSError:
+ return None
+ if head[:4] == b"\x1a\x45\xdf\xa3":
+ return "webm"
+ if len(head) >= 8 and head[4:8] == b"ftyp":
+ return "mp4"
+ return None
+
+
+def take_audio_filter(placement_sec: float, frames: int, source_channels: int) -> str:
+ """The -af chain that turns a raw take into a playable one.
+
+ Resampled to 44.1 kHz first, then placed in output samples, so the
+ placement is rounded once at the output rate (at most half a sample out)
+ rather than at the capture rate and again by the resampler. A take that
+ starts before song 0 (count-in, pickup notes, the latency it is
+ compensated for) is trimmed; one that starts after is delayed. Padded with
+ silence and cut to exactly the stems' frame count, so every lane has the
+ same length.
+ """
+ parts: list[str] = []
+ if source_channels == 1:
+ # Explicit, rather than -ac 2: the mono capture goes to both channels
+ # at unity, with no up-mix coefficients in between.
+ parts.append("pan=stereo|c0=c0|c1=c0")
+ parts.append(f"aresample={TAKE_RATE}")
+ offset = int(round(placement_sec * TAKE_RATE))
+ if offset < 0:
+ parts.append(f"atrim=start_sample={-offset}")
+ parts.append("asetpts=PTS-STARTPTS")
+ elif offset > 0:
+ parts.append(f"adelay=delays={offset}S:all=1")
+ parts.append(f"apad=whole_len={frames}")
+ parts.append(f"atrim=end_sample={frames}")
+ return ",".join(parts)
+
+
+def _run(cmd: list[str], timeout: int, context: str) -> None:
+ proc = subprocess.run( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ )
+ if proc.returncode != 0:
+ tail = proc.stderr[-2000:].decode("utf-8", "replace")
+ raise TakeRenderError(f"ffmpeg failed [{context}]: {tail}")
+
+
+def render_take_audio(
+ raw: Path,
+ dest: Path,
+ placement_sec: float,
+ frames: int,
+ source_rate: int,
+ source_channels: int,
+) -> None:
+ """Render audio.wav from audio.raw.wav. Blocking; written atomically.
+
+ `placement_sec` is the song time of raw frame 0, everything already
+ folded in (round trip, nudge). `frames` is the stems' frame count."""
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp.wav")
+ raw_end = placement_sec + wav_frames(raw) / source_rate
+ try:
+ if raw_end <= 0 or placement_sec * TAKE_RATE >= frames:
+ # Nothing of the take lands inside the song: an all-silent lane
+ # of the right length rather than an ffmpeg graph with no frames.
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ f"anullsrc=r={TAKE_RATE}:cl=stereo",
+ "-af",
+ f"atrim=end_sample={frames}",
+ ]
+ else:
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(raw),
+ "-af",
+ take_audio_filter(placement_sec, frames, source_channels),
+ ]
+ cmd += [
+ "-ac",
+ str(TAKE_CHANNELS),
+ "-ar",
+ str(TAKE_RATE),
+ "-c:a",
+ "pcm_s16le",
+ *WAV_PLAIN_HEADER_ARGS,
+ "-f",
+ "wav",
+ "-y",
+ str(tmp),
+ ]
+ _run(cmd, TIMEOUT_FFMPEG, f"take audio {raw.parent.name}")
+ os.replace(tmp, dest)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+# ---------------------------------------------------------------------------
+# Video render
+
+
+_encoders_cached: str | None = None
+_encoders_lock = threading.Lock()
+
+
+def _encoders() -> str:
+ """`ffmpeg -encoders`, probed once per process when it succeeds (the
+ binary cannot change mid-run). A failed or timed-out probe reads as an
+ empty list for this call only, so the render says the encoder is missing
+ rather than starting and failing later, and the next call probes again
+ (a slow first start must not disable video render for the session)."""
+ global _encoders_cached
+ with _encoders_lock:
+ if _encoders_cached is None:
+ try:
+ probe = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ [ffmpeg_executable(), "-nostdin", "-hide_banner", "-encoders"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=10,
+ )
+ except (OSError, subprocess.SubprocessError):
+ logger.debug(
+ "encoder probe failed; treating video render as unavailable", exc_info=True
+ )
+ return ""
+ if probe.returncode != 0 or not probe.stdout.strip():
+ logger.debug("encoder probe exit %s; not cached", probe.returncode)
+ return ""
+ _encoders_cached = probe.stdout
+ return _encoders_cached
+
+
+def libx264_available() -> bool:
+ """libx264 is GPL and left out of LGPL builds (some distro and Homebrew
+ variants), so it is probed like rubberband rather than assumed."""
+ return bool(re.search(r"^\s*V\S*\s+libx264\b", _encoders(), re.MULTILINE))
+
+
+def aac_available() -> bool:
+ """ffmpeg's native AAC encoder; present in every build seen, probed anyway."""
+ return bool(re.search(r"^\s*A\S*\s+aac\b", _encoders(), re.MULTILINE))
+
+
+class AudioLane(NamedTuple):
+ path: Path
+ gain: float
+ pitch_filter: str # "" or "rubberband=...," as stems._pitch_filter builds it
+ fx_filter: str = "" # "" or "biquad=...," (the lane's EQ, stems.build_render_fx)
+
+
+CANVAS_SIZES = {"16:9": (1920, 1080), "9:16": (1080, 1920)}
+VIDEO_FPS = 30
+# Streamer Mode's outputs: the canvas's short side, and the frame rates.
+# The short side per size: app/core/config.py (one place for the server's numbers).
+VIDEO_FPS_CHOICES = (24, 30, 60)
+
+
+def canvas_size(canvas: str, size: str = "1080") -> tuple[int, int]:
+ """(width, height) of a 16:9, 9:16 or 1:1 canvas whose short side is
+ `size` (720 or 1080). 16:9 and 9:16 at 1080 are CANVAS_SIZES."""
+ short = CANVAS_SHORT_SIDES[size]
+ if canvas == "1:1":
+ return short, short
+ long = short * 16 // 9
+ return (long, short) if canvas == "16:9" else (short, long)
+
+
+def camera_filter(
+ start_sec: float, width: int, height: int, fit: str, mirror: bool, label_in: str
+) -> str:
+ """Scale, crop or letterbox one camera into the canvas and place it in
+ render time. `start_sec` is the render time of the camera's first frame:
+ when positive, the frames are shifted later and the canvas shows black
+ until they arrive; when negative, the frames before render time 0 are cut.
+
+ The frames are shifted first and cut after, so every kept frame keeps its
+ own timestamp. Cutting first and then restarting the timestamps at 0
+ (trim, then setpts=PTS-STARTPTS) moved the whole camera earlier by the
+ distance between the cut and the next frame: 0 to 33 ms at 30 fps,
+ measured 25 ms on a recorded take. The overlay then shows each frame on
+ the nearest frame of the 30 fps grid (measured), so a frame is never off
+ by more than half a frame."""
+ chain = [f"setpts=PTS-STARTPTS+({start_sec:.6f})/TB"]
+ if start_sec < 0:
+ chain.append("trim=start=0")
+ if fit == "contain":
+ chain.append(f"scale={width}:{height}:force_original_aspect_ratio=decrease")
+ chain.append(f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black")
+ elif fit == "fill":
+ # A picture already composed at the canvas's shape (Streamer Mode's
+ # scene): only its size changes, nothing is cut or padded.
+ chain.append(f"scale={width}:{height}")
+ else:
+ chain.append(f"scale={width}:{height}:force_original_aspect_ratio=increase")
+ chain.append(f"crop={width}:{height}")
+ if mirror:
+ chain.append("hflip")
+ chain.append("setsar=1")
+ return f"{label_in}{','.join(chain)}[cam]"
+
+
+def build_video_render_cmd(
+ lanes: list[AudioLane],
+ take_audio: Path,
+ take_gain: float,
+ camera: Path,
+ camera_start_sec: float,
+ duration_sec: float,
+ canvas: str,
+ fit: str,
+ mirror: bool,
+ audio_rate: int,
+ start_sec: float = 0.0,
+ take_fx: str = "",
+ master_fx: str = "",
+ master_chain: str = "",
+ trim_db: float = 0.0,
+ alt_aac: bool = False,
+ size: str = "1080",
+ fps: int = VIDEO_FPS,
+ encoder: str = VIDEO_CPU_ENCODER,
+ encoder_variant: int = 0,
+ encoder_device: str = "",
+) -> list[str]:
+ """One ffmpeg command: backing mix + take audio + one camera tile -> MP4.
+
+ The audio graph is the export's (per-lane pitch and gain, amix with
+ normalize=0, the lossy export limiter), with the take as one more lane at no
+ offset: audio.wav is already aligned to song 0. The output has no path;
+ the caller appends -progress and the destination.
+
+ `start_sec` is the song time where the video starts (0 for the whole
+ song, the count-in for the take's own span) and `duration_sec` its length.
+ Every lane is cut at the same whole sample, before any pitch shift, so the
+ take stays on its sample against the backing; `camera_start_sec` is song
+ time and is moved into the video's time here.
+
+ `take_fx` is the take's EQ (its track's), `master_fx` the master EQ, both
+ "" or a filter prefix ending in a comma, as the lanes' fx_filter.
+
+ `master_chain` is the master (app/pipeline/mastering.py: compressor, gain,
+ true-peak limiter), which replaces the lossy export limiter; `trim_db` and
+ `alt_aac` are the retry of a master whose AAC decoded over its ceiling
+ (stems._render_master_verified).
+
+ `size` (720 or 1080: the canvas's short side) and `fps` (24, 30, 60)
+ are Streamer Mode's output choices; the defaults are the camera take's
+ 1080p at 30 fps.
+
+ `encoder` is libx264 (the default, its arguments exactly as they always
+ were) or a GPU encoder from app/pipeline/video_encoder.py with the
+ argument set `encoder_variant` its probe passed with, and for h264_vaapi
+ the render node `encoder_device` it passed on (required: the render runs
+ where the probe passed, #995). Only the last format filter and the codec
+ arguments change; the graph stays on the CPU."""
+ width, height = canvas_size(canvas, size)
+ if fps not in VIDEO_FPS_CHOICES:
+ raise ValueError(f"unsupported frame rate {fps}")
+ gpu = encoder != VIDEO_CPU_ENCODER
+ if gpu and not video_encoder.is_gpu_encoder(encoder):
+ raise ValueError(f"unknown encoder {encoder}")
+ start_sample = max(0, int(round(start_sec * TAKE_RATE)))
+ start_sec = start_sample / TAKE_RATE
+ # In seconds, not samples, so a lane at another rate is cut at the same
+ # instant; at 44.1 kHz this is exactly start_sample (atrim rounds to the
+ # nearest sample, and a sample is 22.7 microseconds).
+ cut = f"atrim=start={start_sec:.6f},asetpts=PTS-STARTPTS," if start_sample else ""
+ cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
+ if gpu:
+ cmd += video_encoder.global_args(encoder, encoder_device)
+ for lane in lanes:
+ cmd += ["-i", str(lane.path)]
+ take_idx = len(lanes)
+ cam_idx = take_idx + 1
+ cmd += ["-i", str(take_audio), "-i", str(camera)]
+
+ filters = [
+ f"[{i}:a]{cut}{lane.fx_filter}{lane.pitch_filter}volume={lane.gain:.6f}[a{i}]"
+ for i, lane in enumerate(lanes)
+ ]
+ filters.append(f"[{take_idx}:a]{cut}{take_fx}volume={take_gain:.6f}[a{take_idx}]")
+ n = take_idx + 1
+ end = master_chain or EXPORT_LIMITER_LOSSY
+ if trim_db > 0:
+ end += f",volume={10.0 ** (-trim_db / 20.0):.8f}"
+ if n > 1:
+ labels = "".join(f"[a{i}]" for i in range(n))
+ filters.append(f"{labels}amix=inputs={n}:normalize=0,{master_fx}{end}[aout]")
+ else:
+ filters.append(f"[a0]{master_fx}{end}[aout]")
+
+ filters.append(f"color=c=black:s={width}x{height}:r={fps}:d={duration_sec:.6f}[bg]")
+ filters.append(
+ camera_filter(camera_start_sec - start_sec, width, height, fit, mirror, f"[{cam_idx}:v]")
+ )
+ # eof_action=pass: once the camera ends the black canvas carries on, so
+ # the video always has its full length and never freezes on a last frame.
+ out_format = video_encoder.output_filter(encoder) if gpu else "format=yuv420p"
+ filters.append(f"[bg][cam]overlay=eof_action=pass:shortest=0,fps={fps},{out_format}[vout]")
+ cmd += [
+ "-filter_complex",
+ ";".join(filters),
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ ]
+ if gpu:
+ cmd += video_encoder.encoder_args(encoder, width, height, fps, encoder_variant)
+ else:
+ cmd += libx264_args(width, height)
+ cmd += [
+ "-r",
+ str(fps),
+ *(AAC_ENCODE_ARGS_ALT if alt_aac else AAC_ENCODE_ARGS),
+ "-ar",
+ str(audio_rate),
+ "-t",
+ f"{duration_sec:.6f}",
+ # moov before mdat: the file plays while it downloads and every
+ # upload site takes it. Needs a seekable output, so this renders to a
+ # file, never a pipe (unlike get_video_mixdown's fragmented stream).
+ "-movflags",
+ "+faststart",
+ ]
+ return cmd
+
+
+def libx264_args(width: int, height: int) -> list[str]:
+ """The CPU encode's video arguments, unchanged since before the GPU path
+ (a golden test holds them to the byte)."""
+ return [
+ "-c:v",
+ "libx264",
+ "-crf",
+ "20",
+ "-pix_fmt",
+ "yuv420p",
+ ]
+
+
+class RenderCancelled(Exception):
+ pass
+
+
+def sweep_render_temps(jobs_dir: Path) -> int:
+ """Remove render temp files (".render.mp4..tmp.mp4") under every
+ take of every job: called at startup, before anything renders. Returns
+ how many went."""
+ gone = 0
+ for job_dir in job_dirs(jobs_dir):
+ for path in job_dir.glob("takes/*/.render.mp4.*.tmp.mp4"):
+ try:
+ path.unlink()
+ gone += 1
+ except OSError:
+ logger.warning("stale render file not removed: %s", path.name)
+ return gone
+
+
+def run_video_render(
+ cmd: list[str],
+ dest: Path,
+ duration_sec: float,
+ *,
+ cancelled: Callable[[], bool],
+ report: Callable[[float], None],
+ on_proc: Callable[[subprocess.Popen | None], None],
+ timeout: float,
+ check: Callable[[Path], None] | None = None,
+ stall_timeout: float | None = None,
+) -> None:
+ """Run the render to a temp file beside `dest`, reporting progress from
+ ffmpeg's -progress stream, then rename it into place. Blocking.
+
+ Raises RenderCancelled when `cancelled()` turns true (polled about every
+ half second), TakeRenderError on any ffmpeg failure or timeout. Never
+ leaves a partial file at `dest`.
+
+ A GPU render also passes `check` (video_encoder.validate_output on the
+ finished temp file, before it is trusted; its OutputInvalid becomes a
+ TakeRenderError "invalid-") and `stall_timeout`: a render whose
+ progress has not moved for that long is killed ("stall"). The CPU render
+ passes neither and runs exactly as before."""
+ # A cancel that came before (during the encoder test, between a GPU
+ # failure and the CPU render) starts nothing (#790).
+ if cancelled():
+ raise RenderCancelled()
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp.mp4")
+ full = [*cmd, "-progress", "pipe:1", "-nostats", "-f", "mp4", "-y", str(tmp)]
+ proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ full,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ on_proc(proc)
+ stderr_tail: deque[str] = deque(maxlen=40)
+
+ def drain_stderr() -> None:
+ for line in proc.stderr: # type: ignore[union-attr]
+ stderr_tail.append(line.rstrip())
+
+ drain = threading.Thread(target=drain_stderr, daemon=True)
+ drain.start()
+ stop = threading.Event()
+ # Monotonic time of the last forward step of out_time, for the stall check.
+ moved = [time.monotonic(), -1]
+ killed_for: list[str] = []
+
+ def watchdog() -> None:
+ # Cancellation and the overall timeout, checked off the reading
+ # thread: the progress pipe can sit silent for a long time on a
+ # slow encode, and a blocked readline must not delay either.
+ deadline = time.monotonic() + timeout
+ while not stop.wait(0.5):
+ now = time.monotonic()
+ stalled = stall_timeout is not None and now - moved[0] > stall_timeout
+ if cancelled() or now > deadline or stalled:
+ if not cancelled():
+ killed_for.append("stall" if stalled and now <= deadline else "timeout")
+ if proc.poll() is None:
+ proc.kill()
+ return
+
+ guard = threading.Thread(target=watchdog, daemon=True)
+ guard.start()
+ ok = False
+ try:
+ for line in proc.stdout: # type: ignore[union-attr]
+ key, _, value = line.strip().partition("=")
+ # out_time_us is microseconds; out_time_ms is too, despite its name.
+ if key == "out_time_us" and value.lstrip("-").isdigit() and duration_sec > 0:
+ if int(value) > moved[1]:
+ moved[0], moved[1] = time.monotonic(), int(value)
+ report(max(0.0, min(0.99, int(value) / 1e6 / duration_sec)))
+ proc.wait()
+ drain.join(timeout=5)
+ if cancelled():
+ raise RenderCancelled()
+ if proc.returncode != 0:
+ raise TakeRenderError(
+ f"video render exit {proc.returncode}: " + " | ".join(list(stderr_tail)[-8:]),
+ killed_for[0] if killed_for else "exit",
+ stderr="\n".join(stderr_tail),
+ )
+ if check is not None:
+ try:
+ check(tmp)
+ except video_encoder.OutputInvalid as exc:
+ if cancelled():
+ raise RenderCancelled() from None
+ raise TakeRenderError(f"video check failed: {exc}", f"invalid-{exc.code}") from None
+ if cancelled():
+ raise RenderCancelled()
+ os.replace(tmp, dest)
+ ok = True
+ report(1.0)
+ finally:
+ stop.set()
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ on_proc(None)
+ if not ok:
+ tmp.unlink(missing_ok=True)
diff --git a/app/pipeline/transcribe.py b/app/pipeline/transcribe.py
index 054cecfd..bbb8fa59 100644
--- a/app/pipeline/transcribe.py
+++ b/app/pipeline/transcribe.py
@@ -62,6 +62,7 @@
from app.core.models import Job, JobCancelled, _set
from app.core.registry import set_proc
from app.core.settings import lyrics_mending_enabled, transcribe_lyrics_enabled
+from app.core.stem_files import stem_exists, stem_path
from app.pipeline.lyrics_lookup import lyrics_path, read_lyrics, write_lyrics
from app.pipeline.lyrics_repair import looks_stripped, mend_from_transcript, words_of
from app.pipeline.lyrics_retime import save_transcript
@@ -144,7 +145,7 @@ def skip_reason(job: Job, job_dir: Path) -> str | None:
def _vocals_reason(job: Job, job_dir: Path) -> str | None:
"""Why this job's vocals are not worth transcribing, or None."""
- if not (job_dir / "stems" / "vocals.wav").is_file():
+ if not stem_exists(job_dir / "stems", "vocals"):
return "no vocals stem"
presence = (job.stem_presence or {}).get("vocals")
if isinstance(presence, int) and presence < TRANSCRIBE_MIN_VOCAL_PRESENCE:
@@ -483,7 +484,7 @@ def transcribe_lyrics(job: Job, job_dir: Path) -> bool:
logger.info("[%s] lyrics transcription skipped: %s", job.id, reason)
return False
_set(job, stage=_STAGE)
- vocals = job_dir / "stems" / "vocals.wav"
+ vocals = stem_path(job_dir / "stems", "vocals")
result = _run_worker(job, _spawn_worker_cmd(vocals, _device(job)))
if result is None:
return False
@@ -571,7 +572,7 @@ def mend_lyrics(job: Job, job_dir: Path) -> bool:
return False
started = time.monotonic()
_set(job, stage=_STAGE)
- vocals = job_dir / "stems" / "vocals.wav"
+ vocals = stem_path(job_dir / "stems", "vocals")
result = _run_worker(job, _spawn_worker_cmd(vocals, _device(job)) + _language_gate())
if result is None:
return False
diff --git a/app/pipeline/video_encoder.py b/app/pipeline/video_encoder.py
new file mode 100644
index 00000000..facd4623
--- /dev/null
+++ b/app/pipeline/video_encoder.py
@@ -0,0 +1,1360 @@
+"""Which H.264 encoder makes the take render's video, and whether to trust it.
+
+libx264 on the CPU is the default and the answer to every doubt. A GPU encoder
+is used only when all of this holds:
+
+1. The policy allows it: the `video_encoder` setting ("auto", "cpu" or one
+ encoder), overridden by STEMDECK_VIDEO_ENCODER (config.VIDEO_ENCODER_OVERRIDE;
+ "cpu" is the kill switch).
+2. The setting picks it, or Auto tries it on this OS (AUTO: only NVENC on
+ Windows, the one encoder measured in real renders), it has not failed
+ VIDEO_ENCODER_MAX_FAILURES renders in this process (h264_vaapi: on every
+ render node, see below), and a real test encode
+ (probe) at the render's own size and rate passed. `ffmpeg -encoders` is not
+ trusted: a build lists NVENC on a machine with no NVIDIA card, and 4K can
+ fail where 1080p works.
+3. After the render, its file passes validate_output (codec, size, rate,
+ length, audio, file size, a full decode). Anything else, the caller deletes
+ the file and renders again on libx264 (app/api/takes.py).
+
+Probe results are cached per (ffmpeg binary, encoder, geometry) in memory and in
+a JSON file in the data dir, so a render does not pay for a probe each time.
+A pass lasts a week, a failure a day, and a timeout or a busy device only ten
+minutes; a new ffmpeg (another path, size, mtime or version) starts afresh.
+
+h264_vaapi is probed per DRM render node (render_nodes: every
+/dev/dri/renderD* in number order, or the one STEMDECK_VAAPI_DEVICE names),
+and each node's result is cached on its own, so a node that fails never hides
+one that works (#995: a hybrid-GPU laptop's first node can be the GPU without
+a working encoder). The first node that passes is the one the render uses.
+A node's cache key also carries the vendor and device ids of the GPU behind
+it (gpu_id, from sysfs), so a node number that now points at another GPU
+never reuses the old GPU's pass (#1033).
+
+h264_vaapi's real render failures are counted per node: a failure drops that
+node's cached passes, and VIDEO_ENCODER_MAX_FAILURES of them set the node
+aside, so the next render moves on to the next node. The encoder is turned
+off only when every node has been set aside. Every other encoder counts its
+failures for the whole encoder, as before (#1033).
+
+Filters (scale, overlay, fps) stay on the CPU: only the final encode moves.
+Nothing here takes a string from a request: encoder names come from the
+whitelist in config.py, and every subprocess gets list arguments.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import threading
+import time
+import uuid
+from collections.abc import Callable
+from dataclasses import asdict, dataclass, replace
+from pathlib import Path
+
+from app.core.config import (
+ DATA_DIR,
+ TIMEOUT_VIDEO_ENCODER_PROBE,
+ TIMEOUT_VIDEO_VALIDATE_MIN,
+ VAAPI_DEVICE_OVERRIDE,
+ VIDEO_CPU_ENCODER,
+ VIDEO_ENCODER_FAIL_TTL_SEC,
+ VIDEO_ENCODER_MAX_FAILURES,
+ VIDEO_ENCODER_OVERRIDE,
+ VIDEO_ENCODER_PASS_TTL_SEC,
+ VIDEO_ENCODER_PROBE_SEC,
+ VIDEO_ENCODER_RETRY_SEC,
+ VIDEO_ENCODER_TEST_BUDGET_SEC,
+ VIDEO_GPU_ENCODERS,
+ VIDEO_GPU_MAX_BITS_PER_PIXEL,
+ VIDEO_VALIDATE_DURATION_TOL_SEC,
+ VIDEO_VALIDATE_SEC_PER_SEC,
+ ffmpeg_executable,
+ ffprobe_executable,
+)
+
+logger = logging.getLogger("stemdeck.video_encoder")
+
+CPU = VIDEO_CPU_ENCODER
+VAAPI = "h264_vaapi"
+
+# Tried in this order; libx264 is always last and needs no probe. h264_mf is
+# never used (refused 4K on the test machine, no constant-quality mode).
+CANDIDATES: dict[str, tuple[str, ...]] = {
+ "windows": ("h264_nvenc", "h264_qsv", "h264_amf"),
+ "linux": ("h264_nvenc", "h264_vaapi", "h264_qsv"),
+ "macos": ("h264_videotoolbox",),
+}
+
+# Auto tries only these; every other candidate runs only when picked in Settings.
+AUTO: dict[str, tuple[str, ...]] = {
+ "windows": ("h264_nvenc",),
+ "linux": (),
+ "macos": (),
+}
+
+# The vendor behind each encoder, for the "Encoded with" note.
+FAMILY = {
+ "h264_nvenc": "nvidia",
+ "h264_qsv": "intel",
+ "h264_amf": "amd",
+ "h264_vaapi": "vaapi",
+ "h264_videotoolbox": "apple",
+ CPU: "cpu",
+}
+
+# The two geometries the Settings test runs (and the matrix script's 1080p/4K).
+TEST_SIZES = {"1080": (1920, 1080, 30), "2160": (3840, 2160, 30)}
+
+_CACHE_VERSION = 1
+_cache_path = DATA_DIR / "video_encoders.json"
+
+
+def os_key(platform: str | None = None) -> str:
+ plat = platform if platform is not None else sys.platform
+ if plat.startswith("win"):
+ return "windows"
+ if plat == "darwin":
+ return "macos"
+ return "linux"
+
+
+def candidates(platform: str | None = None) -> tuple[str, ...]:
+ """The GPU encoders worth trying on this OS, in order."""
+ return CANDIDATES[os_key(platform)]
+
+
+def is_gpu_encoder(name: str) -> bool:
+ return name in VIDEO_GPU_ENCODERS
+
+
+# ---------------------------------------------------------------------------
+# Encoder arguments
+
+
+# H.264 levels: (name, max macroblocks per frame, per second, max High-profile
+# bitrate in kbit/s). From the spec's Table A-1 (High = 1.25x the base rate).
+_LEVELS = (
+ ("3.1", 3600, 108000, 17500),
+ ("3.2", 5120, 216000, 25000),
+ ("4.0", 8192, 245760, 25000),
+ ("4.1", 8192, 245760, 62500),
+ ("4.2", 8704, 522240, 62500),
+ ("5.0", 22080, 589824, 168750),
+ ("5.1", 36864, 983040, 300000),
+ # 4K at 60 fps needs 5.2 (1944000 macroblocks a second); the app does not
+ # offer 4K60 today, but the table does not have to know that.
+ ("5.2", 36864, 2073600, 300000),
+)
+
+
+def max_bitrate_kbps(width: int, height: int, fps: int) -> int:
+ """The GPU encoders' bitrate ceiling for this geometry."""
+ return int(width * height * fps * VIDEO_GPU_MAX_BITS_PER_PIXEL / 1000)
+
+
+def h264_level(width: int, height: int, fps: int, kbps: int | None = None) -> str:
+ """The lowest level that holds this frame size, rate and bitrate."""
+ mbs = ((width + 15) // 16) * ((height + 15) // 16)
+ rate = kbps if kbps is not None else max_bitrate_kbps(width, height, fps)
+ for name, frame_mbs, mbps, max_kbps in _LEVELS:
+ if mbs <= frame_mbs and mbs * fps <= mbps and rate <= max_kbps:
+ return name
+ return _LEVELS[-1][0]
+
+
+def _level_max_kbps(level: str) -> int:
+ return next(max_kbps for name, _f, _m, max_kbps in _LEVELS if name == level)
+
+
+def variant_count(encoder: str) -> int:
+ """How many argument sets an encoder has, tried in order by the probe:
+ VideoToolbox's constant quality (-q:v) exists only on Apple Silicon, and
+ NVENC's p1-p7 presets only in ffmpeg 4.3 and later."""
+ return 2 if encoder in ("h264_videotoolbox", "h264_nvenc") else 1
+
+
+def encoder_args(encoder: str, width: int, height: int, fps: int, variant: int = 0) -> list[str]:
+ """The output's video codec arguments for a GPU encoder: constant-quality
+ style rate control under a bitrate ceiling, High profile, 8-bit 4:2:0.
+ Quality was matched to libx264 crf 20 on NVENC (measured); the others
+ follow the same intent and are checked by the matrix script, not here.
+
+ NVENC's preset is p4: on the RTX 3080, on the real take render command,
+ p5 ran 1.93x realtime at 4K (as slow as libx264 veryfast) and p4 3.34x,
+ at the same SSIM (0.99987 against a near-lossless render; libx264 crf 20
+ 0.99940). At 1080p p4 ran 10.2x, p5 7.2x, libx264 6.1x."""
+ kbps = max_bitrate_kbps(width, height, fps)
+ level = h264_level(width, height, fps, kbps)
+ cap = ["-maxrate", f"{kbps}k", "-bufsize", f"{2 * kbps}k"]
+ if encoder == "h264_nvenc":
+ rc = (
+ ["-preset", "p4", "-tune", "hq"]
+ if variant == 0
+ else ["-preset", "slow"] # ffmpeg before 4.3: no p1-p7, no -tune
+ )
+ return [
+ "-c:v",
+ "h264_nvenc",
+ *rc,
+ "-rc",
+ "vbr",
+ "-cq",
+ "19",
+ "-b:v",
+ "0",
+ *cap,
+ "-profile:v",
+ "high",
+ "-level",
+ level,
+ "-pix_fmt",
+ "yuv420p",
+ ]
+ if encoder == "h264_qsv":
+ # ICQ (intelligent constant quality); no look-ahead, which some
+ # drivers refuse and which buys little at these rates.
+ return [
+ "-c:v",
+ "h264_qsv",
+ "-preset",
+ "medium",
+ "-global_quality",
+ "21",
+ "-look_ahead",
+ "0",
+ "-profile:v",
+ "high",
+ "-pix_fmt",
+ "nv12",
+ ]
+ if encoder == "h264_amf":
+ return [
+ "-c:v",
+ "h264_amf",
+ "-quality",
+ "quality",
+ "-rc",
+ "cqp",
+ "-qp_i",
+ "20",
+ "-qp_p",
+ "22",
+ "-qp_b",
+ "24",
+ "-profile:v",
+ "high",
+ "-pix_fmt",
+ "nv12",
+ ]
+ if encoder == "h264_vaapi":
+ # The frames are already on the device (output_filter's hwupload).
+ return [
+ "-c:v",
+ "h264_vaapi",
+ "-rc_mode",
+ "CQP",
+ "-global_quality",
+ "21",
+ "-profile:v",
+ "high",
+ ]
+ if encoder == "h264_videotoolbox":
+ quality = ["-q:v", "65"] if variant == 0 else ["-b:v", f"{kbps // 3}k", *cap]
+ return [
+ "-c:v",
+ "h264_videotoolbox",
+ *quality,
+ "-profile:v",
+ "high",
+ "-pix_fmt",
+ "yuv420p",
+ ]
+ raise ValueError(f"not a GPU encoder: {encoder}")
+
+
+# Where the DRM render nodes are (a module attribute so a test can point it at
+# a fake tree).
+DRI_DIR = Path("/dev/dri")
+_RENDER_NODE_RE = re.compile(r"renderD([0-9]+)")
+
+
+def _host_platform() -> str:
+ return sys.platform
+
+
+def render_nodes() -> list[str]:
+ """The DRM render nodes h264_vaapi may run on, in the order they are
+ tried: the one STEMDECK_VAAPI_DEVICE names when it is set (and exists),
+ else every renderD* in number order (renderD128, renderD129, ...).
+ Empty on Windows and macOS, which have no VAAPI, and wherever there is
+ no node (WSL, most containers)."""
+ plat = _host_platform()
+ if plat.startswith("win") or plat == "darwin":
+ return []
+ if VAAPI_DEVICE_OVERRIDE:
+ forced = DRI_DIR / Path(VAAPI_DEVICE_OVERRIDE).name
+ return [str(forced)] if forced.exists() else []
+ try:
+ found = [
+ (int(m.group(1)), path)
+ for path in DRI_DIR.iterdir()
+ if (m := _RENDER_NODE_RE.fullmatch(path.name))
+ ]
+ except OSError:
+ return []
+ return [str(path) for _number, path in sorted(found)]
+
+
+# Where the kernel describes each DRM node (a module attribute so a test can
+# point it at a fake tree).
+SYS_DRM_DIR = Path("/sys/class/drm")
+_PCI_ID_RE = re.compile(r"0x([0-9a-fA-F]{4})")
+
+
+def _read_pci_id(path: Path) -> str:
+ """A sysfs id file ("0x8086") as four lowercase hex digits, "" when it is
+ missing, unreadable or not an id."""
+ try:
+ raw = path.read_text(encoding="ascii", errors="replace")[:32].strip()
+ except OSError:
+ return ""
+ m = _PCI_ID_RE.fullmatch(raw)
+ return m.group(1).lower() if m else ""
+
+
+def gpu_id(device: str) -> str:
+ """The GPU behind render node `device` as "vendor:device" PCI ids
+ ("8086:46a6"), read from /sys/class/drm/renderD/device/. Node numbers
+ can change after a driver or boot order change on a hybrid-GPU machine;
+ these ids do not. "" when nothing is known (no node, not Linux, no
+ sysfs, a GPU that is not PCI): the cache key still works, it is then
+ keyed on the node alone. Never shown outside the cache key."""
+ if not device:
+ return ""
+ plat = _host_platform()
+ if plat.startswith("win") or plat == "darwin":
+ return ""
+ base = SYS_DRM_DIR / Path(device).name / "device"
+ ids = [_read_pci_id(base / "vendor"), _read_pci_id(base / "device")]
+ return ":".join(ids) if any(ids) else ""
+
+
+def global_args(encoder: str, device: str = "") -> list[str]:
+ """Options that go before the inputs: VAAPI's render node. -vaapi_device
+ opens it and also makes it the filter graph's device, so output_filter's
+ hwupload puts the frames on the same node the encoder runs on."""
+ if encoder == VAAPI:
+ if not device:
+ raise ValueError("h264_vaapi needs the render node its probe passed on")
+ return ["-vaapi_device", device]
+ return []
+
+
+def output_filter(encoder: str) -> str:
+ """The last filter of the video chain: the CPU picture as the encoder takes it."""
+ return "format=nv12,hwupload" if encoder == "h264_vaapi" else "format=yuv420p"
+
+
+def size_cap_bytes(width: int, height: int, fps: int, duration: float) -> int:
+ """The largest file a GPU render of this length may honestly be: its
+ level's bitrate plus the 192 kbps AAC, with half again for the container
+ and rounding. Anything bigger is a runaway encoder."""
+ level = h264_level(width, height, fps)
+ kbps = _level_max_kbps(level) + 256
+ return int(max(duration, 1.0) * kbps * 1000 / 8 * 1.5) + 1_000_000
+
+
+# ---------------------------------------------------------------------------
+# The ffmpeg binary's identity
+
+
+_ident_cache: dict[tuple[str, int, int], tuple[str, str]] = {}
+_ident_lock = threading.Lock()
+
+
+def _resolve(exe: str) -> str | None:
+ if os.path.isfile(exe):
+ return os.path.abspath(exe)
+ return shutil.which(exe)
+
+
+def ffmpeg_identity() -> tuple[str, str]:
+ """(id, version line) of the ffmpeg this process runs. The id changes when
+ the binary does (path, size, mtime or version), which drops every cached
+ probe. ("", "") when there is no binary to run."""
+ path = _resolve(ffmpeg_executable())
+ if path is None:
+ return "", ""
+ try:
+ st = os.stat(path)
+ except OSError:
+ return "", ""
+ key = (path, st.st_size, st.st_mtime_ns)
+ with _ident_lock:
+ hit = _ident_cache.get(key)
+ if hit is not None:
+ return hit
+ try:
+ out = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ [path, "-hide_banner", "-version"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_VIDEO_ENCODER_PROBE,
+ )
+ except (OSError, subprocess.SubprocessError):
+ logger.debug("ffmpeg -version failed", exc_info=True)
+ return "", ""
+ version = (out.stdout.splitlines() or [""])[0].strip()[:200]
+ if out.returncode != 0 or not version:
+ return "", ""
+ ident = hashlib.sha256(f"{path}|{st.st_size}|{st.st_mtime_ns}|{version}".encode()).hexdigest()
+ result = (ident[:16], version)
+ with _ident_lock:
+ _ident_cache[key] = result
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Probe
+
+
+@dataclass
+class ProbeResult:
+ encoder: str
+ width: int
+ height: int
+ fps: int
+ ok: bool
+ # "" when ok; else missing (not in this ffmpeg), nodevice, failed, busy,
+ # timeout, error (could not start), disabled, budget (Test ran out of time),
+ # cancelled (the render that asked was cancelled: never cached).
+ reason: str = ""
+ detail: str = "" # ffmpeg's error line, cleaned (no addresses, no paths)
+ speed: float | None = None # x realtime of the test encode
+ variant: int = 0
+ # h264_vaapi only: the render node this result is about (a full path),
+ # "" for every other encoder and for "no node passed".
+ device: str = ""
+ checked_at: float = 0.0
+ transient: bool = False
+
+ def expires_at(self) -> float:
+ if self.ok:
+ ttl = VIDEO_ENCODER_PASS_TTL_SEC
+ elif self.transient:
+ ttl = VIDEO_ENCODER_RETRY_SEC
+ else:
+ ttl = VIDEO_ENCODER_FAIL_TTL_SEC
+ return self.checked_at + ttl
+
+ def view(self) -> dict:
+ d = asdict(self)
+ d.pop("transient", None)
+ d.pop("checked_at", None)
+ # The node's name (renderD129), never a path.
+ d["device"] = Path(self.device).name if self.device else ""
+ return d
+
+
+# Signs of a device that is there but taken or short of memory right now (an
+# NVENC session limit while OBS streams, another render): worth another try
+# in a few minutes, never a lasting "does not work".
+_TRANSIENT_RE = re.compile(
+ r"out of memory|busy|temporarily unavailable|incompatible client key|"
+ r"too many|no free|try again",
+ re.IGNORECASE,
+)
+_MISSING_RE = re.compile(r"unknown encoder|encoder not found", re.IGNORECASE)
+# ffmpeg's own noise around the real error.
+_NOISE_RE = re.compile(
+ r"could not open encoder before eof|task finished with error|terminating thread|"
+ r"nothing was written|error while filtering|error opening output|error selecting|"
+ r"conversion failed|^\s*$",
+ re.IGNORECASE,
+)
+_PREFIX_RE = re.compile(r"^(\[[^\]]*\]\s*)+")
+_ADDRESS_RE = re.compile(r"0x[0-9a-fA-F]+")
+
+
+def clean_error_line(stderr: str) -> str:
+ """The most telling line of an ffmpeg error: the first that is not one of
+ its generic wrap-up messages, without the "[enc @ 0x...]" prefixes.
+ Printable ASCII, at most 160 characters."""
+ lines = [ln.strip() for ln in stderr.splitlines() if ln.strip()]
+ pick = next((ln for ln in lines if not _NOISE_RE.search(ln)), lines[0] if lines else "")
+ pick = _ADDRESS_RE.sub("", _PREFIX_RE.sub("", pick))
+ pick = "".join(c for c in pick if 32 <= ord(c) < 127).strip()
+ return pick[:160]
+
+
+def classify_failure(stderr: str) -> tuple[str, bool]:
+ """(reason, transient) for an encode that exited non-zero."""
+ if _MISSING_RE.search(stderr):
+ return "missing", False
+ if _TRANSIENT_RE.search(stderr):
+ return "busy", True
+ return "failed", False
+
+
+# A render that failed over its files, not its encoder: ffmpeg prints the
+# system's own words for these (a full disk, a missing or unreadable file, a
+# failing drive), the same whichever encoder runs.
+_FILES_RE = re.compile(
+ r"no space left on device|disk quota exceeded|file too large|no such file or directory|"
+ r"permission denied|input/output error|read-only file system|error opening input",
+ re.IGNORECASE,
+)
+
+
+def blames_encoder(encoder: str, code: str, stderr: str) -> bool:
+ """Whether a failed GPU render counts toward VIDEO_ENCODER_MAX_FAILURES
+ (#791). A stall, the timeout and a file that fails the check
+ ("invalid-") count, as before.
+
+ An ffmpeg exit counts only when the encoder itself said so: a line logged
+ by it ("[h264_nvenc @ ...]", or ffmpeg's encoder task "[enc:h264_nvenc @
+ ...]"), on a failure that is neither about the files nor a busy device
+ (classify_failure). Anything else does not count: only NVENC on Windows
+ has been seen failing, other vendors' words are unknown, and a wrong
+ count turns the encoder off until a restart while a missed one costs a
+ single quick GPU attempt before the CPU render."""
+ if code != "exit":
+ return True
+ if _FILES_RE.search(stderr):
+ return False
+ reason, transient = classify_failure(stderr)
+ if transient:
+ return False
+ if reason == "missing":
+ return True
+ own = re.compile(rf"\[(?:enc:)?{re.escape(encoder)} @", re.IGNORECASE)
+ return own.search(stderr) is not None
+
+
+def probe_cmd(
+ encoder: str, width: int, height: int, fps: int, variant: int = 0, device: str = ""
+) -> list[str]:
+ """A real encode of a moving test picture at the render's geometry, thrown
+ away (-f null), with -progress so the frames that came out are counted.
+ `device` is h264_vaapi's render node, exactly as the render passes it."""
+ return [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-hide_banner",
+ "-v",
+ "error",
+ *global_args(encoder, device),
+ "-f",
+ "lavfi",
+ "-i",
+ f"testsrc2=s={width}x{height}:r={fps}:d={VIDEO_ENCODER_PROBE_SEC}",
+ "-vf",
+ output_filter(encoder),
+ *encoder_args(encoder, width, height, fps, variant),
+ "-r",
+ str(fps),
+ "-progress",
+ "pipe:1",
+ "-nostats",
+ "-f",
+ "null",
+ "-",
+ ]
+
+
+def _never() -> bool:
+ return False
+
+
+# How often a probe, or a render waiting for the probe lock, looks at its
+# cancel flag (#790).
+_CANCEL_POLL_SEC = 0.25
+
+
+def _run_probe_once(
+ encoder: str,
+ width: int,
+ height: int,
+ fps: int,
+ variant: int,
+ cancelled: Callable[[], bool] = _never,
+ device: str = "",
+) -> ProbeResult:
+ now = time.time()
+ base = {"encoder": encoder, "width": width, "height": height, "fps": fps}
+ base["variant"] = variant
+ base["device"] = device
+ cmd = probe_cmd(encoder, width, height, fps, variant, device)
+ start = time.monotonic()
+ try:
+ proc = subprocess.Popen( # noqa: S603 -- list args from the whitelist, trusted ffmpeg
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ except OSError as exc:
+ return ProbeResult(
+ **base,
+ ok=False,
+ reason="error",
+ detail=type(exc).__name__,
+ checked_at=now,
+ transient=True,
+ )
+ stop = threading.Event()
+ stopped: list[bool] = []
+
+ def watchdog() -> None:
+ # A cancel of the render that asked kills the test encode at once.
+ while not stop.wait(_CANCEL_POLL_SEC):
+ if cancelled():
+ stopped.append(True)
+ if proc.poll() is None:
+ proc.kill()
+ return
+
+ guard = threading.Thread(target=watchdog, daemon=True)
+ guard.start()
+ try:
+ out, err = proc.communicate(timeout=TIMEOUT_VIDEO_ENCODER_PROBE)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.communicate()
+ return ProbeResult(**base, ok=False, reason="timeout", checked_at=now, transient=True)
+ finally:
+ stop.set()
+ if stopped:
+ return ProbeResult(**base, ok=False, reason="cancelled", checked_at=now, transient=True)
+ wall = max(time.monotonic() - start, 1e-3)
+ if proc.returncode != 0:
+ reason, transient = classify_failure(err)
+ return ProbeResult(
+ **base,
+ ok=False,
+ reason=reason,
+ detail=clean_error_line(err),
+ checked_at=now,
+ transient=transient,
+ )
+ frames = [int(v) for v in re.findall(r"^frame=(\d+)", out, re.MULTILINE)]
+ want = VIDEO_ENCODER_PROBE_SEC * fps
+ if not frames or frames[-1] < want * 0.9:
+ return ProbeResult(
+ **base,
+ ok=False,
+ reason="failed",
+ detail=f"{frames[-1] if frames else 0} of {want} frames",
+ checked_at=now,
+ )
+ return ProbeResult(**base, ok=True, speed=round(frames[-1] / fps / wall, 2), checked_at=now)
+
+
+def run_probe(
+ encoder: str,
+ width: int,
+ height: int,
+ fps: int,
+ cancelled: Callable[[], bool] = _never,
+ device: str = "",
+) -> ProbeResult:
+ """Probe every argument variant of `encoder` (on render node `device`
+ for h264_vaapi) in order; the first that passes wins. The last failure
+ is the answer when none does."""
+ result = _run_probe_once(encoder, width, height, fps, 0, cancelled, device)
+ for variant in range(1, variant_count(encoder)):
+ # A missing encoder, a timeout, a cancel: another variant cannot help.
+ if result.ok or result.reason in ("missing", "timeout", "error", "cancelled"):
+ break
+ result = _run_probe_once(encoder, width, height, fps, variant, cancelled, device)
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Probe cache
+
+_cache: dict[str, ProbeResult] = {}
+_cache_loaded_for: str | None = None
+_cache_lock = threading.RLock()
+# Serialises _save_cache (snapshot to os.replace). Taken before _cache_lock.
+_save_lock = threading.Lock()
+_probe_lock = threading.Lock()
+
+
+def _args_sig(encoder: str, width: int, height: int, fps: int, device: str = "") -> str:
+ """A fingerprint of what a probe of this encoder runs, so a release that
+ changes the arguments never trusts a pass of the old ones."""
+ parts = [
+ # h264_vaapi with no node is the "no node" entry: nothing runs.
+ global_args(encoder, device) if device or encoder != VAAPI else [],
+ output_filter(encoder),
+ [encoder_args(encoder, width, height, fps, v) for v in range(variant_count(encoder))],
+ ]
+ return hashlib.sha256(json.dumps(parts).encode()).hexdigest()[:10]
+
+
+def _key(ident: str, encoder: str, width: int, height: int, fps: int, device: str = "") -> str:
+ """The cache key. h264_vaapi has one per render node (`device`) and the
+ GPU behind it (`node#vendor:device`, gpu_id; `node#` when the ids are
+ unknown), and one with no node for "this machine has none". A key of the
+ format before the ids (`|node|`) never matches, so such a row is probed
+ again once (#1033)."""
+ sig = _args_sig(encoder, width, height, fps, device)
+ node = f"|{device}#{gpu_id(device)}" if device else ""
+ return f"{ident}|{encoder}|{width}x{height}@{fps}{node}|{sig}"
+
+
+def _load_cache(ident: str) -> None:
+ """Fill the in-memory cache from the JSON file once per ffmpeg binary,
+ keeping only this binary's unexpired entries."""
+ global _cache_loaded_for
+ with _cache_lock:
+ if _cache_loaded_for == ident:
+ return
+ _cache.clear()
+ _cache_loaded_for = ident
+ try:
+ data = json.loads(_cache_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return
+ except (OSError, ValueError):
+ logger.warning("video encoder cache unreadable; probing afresh", exc_info=True)
+ return
+ if not isinstance(data, dict) or data.get("v") != _CACHE_VERSION:
+ return
+ if data.get("ffmpeg") != ident:
+ return
+ entries = data.get("entries")
+ if not isinstance(entries, dict):
+ # A damaged or edited file: probe afresh, never raise (probe()).
+ return
+ now = time.time()
+ for key, row in entries.items():
+ if not isinstance(key, str) or not isinstance(row, dict):
+ continue
+ try:
+ result = ProbeResult(**row)
+ except TypeError:
+ continue
+ if not key.startswith(f"{ident}|") or result.encoder not in VIDEO_GPU_ENCODERS:
+ continue
+ if result.expires_at() > now:
+ _cache[key] = result
+
+
+def _save_cache(ident: str) -> None:
+ """Write the in-memory cache to the file. The snapshot and the replace
+ happen under _save_lock, so two saves land in the order they took their
+ snapshots: a probe's older snapshot can never overwrite a newer one, such
+ as the save that dropped a failed node's pass (#1033). Lock order is
+ _save_lock, then _cache_lock; nothing calls this holding _cache_lock."""
+ with _save_lock:
+ with _cache_lock:
+ data = {
+ "v": _CACHE_VERSION,
+ "ffmpeg": ident,
+ "entries": {k: asdict(r) for k, r in _cache.items() if k.startswith(f"{ident}|")},
+ }
+ try:
+ _cache_path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = _cache_path.with_name(f"{_cache_path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data), encoding="utf-8")
+ os.replace(tmp, _cache_path)
+ finally:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ # The in-memory copy still applies for this run.
+ logger.warning("video encoder cache not written", exc_info=True)
+
+
+def probe(
+ encoder: str,
+ width: int,
+ height: int,
+ fps: int,
+ force: bool = False,
+ cancelled: Callable[[], bool] = _never,
+) -> ProbeResult:
+ """The cached probe result for this encoder and geometry, probing when
+ there is none, it expired, or `force`. Never raises.
+
+ h264_vaapi is probed node by node (_vaapi_nodes: render_nodes without
+ the nodes real renders set aside), each node cached on its own; the
+ answer is the first node that passes, with that node in `device`, or a
+ failure naming every node when none does.
+
+ `cancelled` (a render's cancel flag) is looked at while waiting for the
+ probe lock and during the test encode; a cancelled probe answers
+ "cancelled" and is never cached."""
+ if encoder not in VIDEO_GPU_ENCODERS:
+ raise ValueError(f"not a GPU encoder: {encoder}")
+ ident, _version = ffmpeg_identity()
+ if not ident:
+ return ProbeResult(
+ encoder,
+ width,
+ height,
+ fps,
+ ok=False,
+ reason="error",
+ checked_at=time.time(),
+ transient=True,
+ )
+ if encoder != VAAPI:
+ return _probe_device(ident, encoder, width, height, fps, "", force, cancelled)
+ nodes = _vaapi_nodes()
+ if not nodes:
+ return _no_node(ident, width, height, fps)
+ failures: list[ProbeResult] = []
+ for node in nodes:
+ result = _probe_device(ident, encoder, width, height, fps, node, force, cancelled)
+ if result.ok or result.reason == "cancelled":
+ return result
+ failures.append(result)
+ if result.reason == "missing":
+ # This ffmpeg has no h264_vaapi: the same on every node.
+ break
+ return _none_passed(failures)
+
+
+def _no_node(ident: str, width: int, height: int, fps: int) -> ProbeResult:
+ """h264_vaapi on a machine with no render node to try (or without the
+ one STEMDECK_VAAPI_DEVICE names). Kept in the cache only so Settings
+ shows it: render_nodes is looked at again before every probe, so a node
+ that appears later is tried at once."""
+ forced = Path(VAAPI_DEVICE_OVERRIDE).name if VAAPI_DEVICE_OVERRIDE else ""
+ result = ProbeResult(
+ VAAPI,
+ width,
+ height,
+ fps,
+ ok=False,
+ reason="nodevice",
+ detail=f"{forced} not found (STEMDECK_VAAPI_DEVICE)" if forced else "",
+ checked_at=time.time(),
+ )
+ key = _key(ident, VAAPI, width, height, fps)
+ hit = _valid_hit(ident, key)
+ if hit is None or hit.reason != result.reason or hit.detail != result.detail:
+ with _cache_lock:
+ _cache[key] = result
+ _save_cache(ident)
+ return result
+
+
+def _none_passed(failures: list[ProbeResult]) -> ProbeResult:
+ """One answer for h264_vaapi when no node passed: a busy or timed-out
+ node's (it may pass in a few minutes), else the last node's, with each
+ node's own error in `detail`."""
+ transient = [r for r in failures if r.transient]
+ pick = transient[0] if transient else failures[-1]
+ detail = "; ".join(f"{Path(r.device).name}: {r.detail or r.reason}" for r in failures)
+ return replace(pick, detail=detail[:160], device="")
+
+
+def _probe_device(
+ ident: str,
+ encoder: str,
+ width: int,
+ height: int,
+ fps: int,
+ device: str,
+ force: bool,
+ cancelled: Callable[[], bool],
+) -> ProbeResult:
+ """probe() for one encoder on one device ("" for all but h264_vaapi)."""
+ key = _key(ident, encoder, width, height, fps, device)
+
+ def gave_up() -> ProbeResult:
+ return ProbeResult(
+ encoder,
+ width,
+ height,
+ fps,
+ ok=False,
+ reason="cancelled",
+ device=device,
+ checked_at=time.time(),
+ transient=True,
+ )
+
+ if not force:
+ # A cached answer needs no probe lock, which another probe (Settings'
+ # Test encoders) can hold for minutes (#791).
+ hit = _valid_hit(ident, key, device)
+ if hit is not None:
+ return hit
+ while not _probe_lock.acquire(timeout=_CANCEL_POLL_SEC):
+ if cancelled():
+ return gave_up()
+ try:
+ hit = _valid_hit(ident, key, device)
+ if hit is not None and not force:
+ return hit
+ if cancelled():
+ return gave_up()
+ result = run_probe(encoder, width, height, fps, cancelled, device)
+ if result.reason == "cancelled":
+ return result
+ logger.info(
+ "video encoder probe %s%s %dx%d@%d: %s%s",
+ encoder,
+ f" on {device}" if device else "",
+ width,
+ height,
+ fps,
+ "ok" if result.ok else result.reason,
+ f" ({result.detail})" if result.detail else "",
+ )
+ if hit is not None and hit.ok and not result.ok and result.transient:
+ # A forced probe (Test encoders) that only met a busy card, a
+ # timeout: the pass stands for renders; the test still reports
+ # what it saw (#791).
+ return result
+ with _cache_lock:
+ _cache[key] = result
+ _save_cache(ident)
+ return result
+ finally:
+ _probe_lock.release()
+
+
+def _valid_hit(ident: str, key: str, device: str = "") -> ProbeResult | None:
+ """The cached result for `key` while it is still trusted, else None.
+ `device` is the render node the key was built for: a row naming any
+ other (an edited or damaged video_encoders.json) is not trusted, so only
+ a node render_nodes() listed can reach -vaapi_device."""
+ _load_cache(ident)
+ with _cache_lock:
+ hit = _cache.get(key)
+ if hit is not None and hit.device == device and hit.expires_at() > time.time():
+ return hit
+ return None
+
+
+def cached_results() -> list[ProbeResult]:
+ """What the cache holds for this ffmpeg, without probing anything."""
+ ident, _version = ffmpeg_identity()
+ if not ident:
+ return []
+ # Only the cache: no probe lock, so a running Test never holds it (#791).
+ _load_cache(ident)
+ with _cache_lock:
+ now = time.time()
+ live = [r for k, r in _cache.items() if k.startswith(f"{ident}|") and r.expires_at() > now]
+ out = [r for r in live if r.encoder != VAAPI]
+ # h264_vaapi: one answer per geometry, the one a render would get.
+ nodes = _vaapi_nodes()
+ for width, height, fps in sorted(
+ {(r.width, r.height, r.fps) for r in live if r.encoder == VAAPI}
+ ):
+ result = _vaapi_cached(ident, width, height, fps, nodes)
+ if result is not None:
+ out.append(result)
+ return out
+
+
+def _vaapi_cached(
+ ident: str, width: int, height: int, fps: int, nodes: list[str]
+) -> ProbeResult | None:
+ """What probe() would answer for h264_vaapi from the cache alone: the
+ first node in order that passed, the combined failure when every node
+ has failed, None while a node before any pass is still untested."""
+ if not nodes:
+ return _valid_hit(ident, _key(ident, VAAPI, width, height, fps))
+ failures = []
+ for node in nodes:
+ hit = _valid_hit(ident, _key(ident, VAAPI, width, height, fps, node), node)
+ if hit is None:
+ return None
+ if hit.ok:
+ return hit
+ failures.append(hit)
+ return _none_passed(failures)
+
+
+# ---------------------------------------------------------------------------
+# Failures in real renders
+
+# Keyed (encoder, render node): the node is "" for every encoder but
+# h264_vaapi, so those count per encoder exactly as before #1033.
+_failures: dict[tuple[str, str], int] = {}
+_disabled: set[str] = set()
+# h264_vaapi render nodes set aside after VIDEO_ENCODER_MAX_FAILURES real
+# render failures, until the next start.
+_set_aside: set[str] = set()
+_fail_lock = threading.Lock()
+
+
+def _counted_device(encoder: str, device: str) -> str:
+ """The node a failure is counted against: h264_vaapi's own, else none."""
+ return device if encoder == VAAPI else ""
+
+
+def _vaapi_nodes() -> list[str]:
+ """The render nodes h264_vaapi tries, in order: render_nodes without
+ the ones real renders set aside. When every node is set aside the
+ encoder is disabled and no render gets here, so the list is all of them
+ again: the Settings test still reports what each node does."""
+ nodes = render_nodes()
+ with _fail_lock:
+ usable = [n for n in nodes if n not in _set_aside]
+ return usable or nodes
+
+
+def _drop_passes(encoder: str, device: str) -> None:
+ """Forget every cached pass of `encoder` on `device` (all geometries), in
+ memory and in the file: a real render there just failed, so the 2 s test
+ encode that passed is no longer trusted and runs again next time."""
+ with _cache_lock:
+ ident = _cache_loaded_for
+ gone = [
+ k for k, r in _cache.items() if r.ok and r.encoder == encoder and r.device == device
+ ]
+ for k in gone:
+ del _cache[k]
+ if gone and ident:
+ _save_cache(ident)
+
+
+def record_failure(encoder: str, reason: str, device: str = "") -> bool:
+ """Count a GPU render that had to be made again on the CPU. Returns True
+ when this one disabled the encoder for the rest of the process.
+
+ h264_vaapi counts per render node (`device`, the node the render ran
+ on): each failure drops that node's cached passes, and the node that
+ reaches VIDEO_ENCODER_MAX_FAILURES is set aside so the next render
+ tries the next node. The encoder is disabled only once every node is
+ set aside. Every other encoder ignores `device` and counts as a whole."""
+ if encoder not in VIDEO_GPU_ENCODERS:
+ return False
+ node = _counted_device(encoder, device)
+ if node:
+ _drop_passes(encoder, node)
+ nodes = render_nodes() if node else []
+ with _fail_lock:
+ key = (encoder, node)
+ _failures[key] = _failures.get(key, 0) + 1
+ count = _failures[key]
+ aside = False
+ if node:
+ aside = count >= VIDEO_ENCODER_MAX_FAILURES and node not in _set_aside
+ if aside:
+ _set_aside.add(node)
+ # Off only when no listed node is left to try.
+ done = bool(nodes) and all(n in _set_aside for n in nodes)
+ else:
+ done = count >= VIDEO_ENCODER_MAX_FAILURES
+ newly = done and encoder not in _disabled
+ if newly:
+ _disabled.add(encoder)
+ name = Path(node).name if node else ""
+ logger.warning(
+ "GPU encoder %s%s failed a render (%s), %d of %d%s",
+ encoder,
+ f" on {name}" if name else "",
+ reason,
+ count,
+ VIDEO_ENCODER_MAX_FAILURES,
+ "; not used again until StemDeck restarts"
+ if newly
+ else (
+ f"; {name} set aside until StemDeck restarts, the next node is tried" if aside else ""
+ ),
+ )
+ return newly
+
+
+def disabled() -> list[str]:
+ with _fail_lock:
+ return sorted(_disabled)
+
+
+def failure_count(encoder: str, device: str = "") -> int:
+ """Real render failures counted against `encoder` (on `device`, for
+ h264_vaapi's per-node count)."""
+ with _fail_lock:
+ return _failures.get((encoder, _counted_device(encoder, device)), 0)
+
+
+def reset_state() -> None:
+ """Forget failures and the in-memory cache (tests)."""
+ global _cache_loaded_for
+ with _fail_lock:
+ _failures.clear()
+ _disabled.clear()
+ _set_aside.clear()
+ with _cache_lock:
+ _cache.clear()
+ _cache_loaded_for = None
+ with _ident_lock:
+ _ident_cache.clear()
+
+
+# ---------------------------------------------------------------------------
+# Policy and choice
+
+
+def policy() -> tuple[str, str]:
+ """(value, source): the env override when set ("env"), else the setting."""
+ if VIDEO_ENCODER_OVERRIDE:
+ return VIDEO_ENCODER_OVERRIDE, "env"
+ from app.core.settings import get_video_encoder
+
+ return get_video_encoder(), "setting"
+
+
+@dataclass
+class Choice:
+ encoder: str
+ variant: int = 0
+ # Why not a GPU encoder, for the log: "policy", "none" (nothing passed),
+ # or the last probe's reason.
+ reason: str = ""
+ # h264_vaapi: the render node that passed, for the render's -vaapi_device.
+ device: str = ""
+
+
+def choose(width: int, height: int, fps: int, cancelled: Callable[[], bool] = _never) -> Choice:
+ """The encoder for a render of this geometry. Blocking (may probe).
+ Gives up with reason "cancelled" as soon as `cancelled()` is true."""
+ value, _source = policy()
+ if value == "cpu":
+ return Choice(CPU, reason="policy")
+ order = (value,) if value in VIDEO_GPU_ENCODERS else AUTO[os_key()]
+ reason = "none"
+ for encoder in order:
+ if cancelled():
+ return Choice(CPU, reason="cancelled")
+ if encoder in disabled():
+ reason = "disabled"
+ continue
+ result = probe(encoder, width, height, fps, cancelled=cancelled)
+ if result.ok:
+ return Choice(encoder, result.variant, device=result.device)
+ if result.reason == "cancelled":
+ return Choice(CPU, reason="cancelled")
+ reason = result.reason or "failed"
+ return Choice(CPU, reason=reason)
+
+
+# ---------------------------------------------------------------------------
+# The Settings test
+
+
+def test_all(budget_sec: float = VIDEO_ENCODER_TEST_BUDGET_SEC) -> list[dict]:
+ """Probe every candidate of this OS afresh at 1080p30 and 4K30, and
+ libx264 for comparison, within `budget_sec`. One row per encoder:
+ {"encoder", "family", "disabled", "results": {"1080": {...}, "2160": {...}}}."""
+ deadline = time.monotonic() + budget_sec
+ rows = []
+ for encoder in (*candidates(), CPU):
+ row = {
+ "encoder": encoder,
+ "family": FAMILY[encoder],
+ "disabled": encoder in disabled(),
+ "results": {},
+ }
+ for size, (w, h, fps) in TEST_SIZES.items():
+ if time.monotonic() >= deadline:
+ result = ProbeResult(encoder, w, h, fps, ok=False, reason="budget")
+ elif encoder == CPU:
+ result = _probe_cpu(w, h, fps)
+ else:
+ result = probe(encoder, w, h, fps, force=True)
+ row["results"][size] = result.view()
+ rows.append(row)
+ return rows
+
+
+def _probe_cpu(width: int, height: int, fps: int) -> ProbeResult:
+ """libx264 with the render's own settings, for the speed to compare with."""
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-hide_banner",
+ "-v",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ f"testsrc2=s={width}x{height}:r={fps}:d={VIDEO_ENCODER_PROBE_SEC}",
+ "-c:v",
+ CPU,
+ *(["-preset", "veryfast"] if min(width, height) >= 2160 else []),
+ "-crf",
+ "20",
+ "-pix_fmt",
+ "yuv420p",
+ "-f",
+ "null",
+ "-",
+ ]
+ start = time.monotonic()
+ try:
+ proc = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ cmd,
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=TIMEOUT_VIDEO_ENCODER_PROBE,
+ )
+ except subprocess.TimeoutExpired:
+ return ProbeResult(CPU, width, height, fps, ok=False, reason="timeout", transient=True)
+ except OSError:
+ return ProbeResult(CPU, width, height, fps, ok=False, reason="error", transient=True)
+ wall = max(time.monotonic() - start, 1e-3)
+ if proc.returncode != 0:
+ reason, transient = classify_failure(proc.stderr)
+ detail = clean_error_line(proc.stderr)
+ return ProbeResult(
+ CPU, width, height, fps, ok=False, reason=reason, detail=detail, transient=transient
+ )
+ return ProbeResult(
+ CPU, width, height, fps, ok=True, speed=round(VIDEO_ENCODER_PROBE_SEC / wall, 2)
+ )
+
+
+# ---------------------------------------------------------------------------
+# Validation of a GPU render
+
+
+class OutputInvalid(Exception):
+ """The file is not what was asked for. `code` names the check."""
+
+ def __init__(self, code: str, detail: str = "") -> None:
+ super().__init__(f"{code}: {detail}" if detail else code)
+ self.code = code
+
+
+def _fraction(value: object) -> float:
+ try:
+ num, _, den = str(value).partition("/")
+ return float(num) / float(den or 1)
+ except (ValueError, ZeroDivisionError):
+ return 0.0
+
+
+def _run_watched(
+ cmd: list[str],
+ timeout: float,
+ cancelled: Callable[[], bool],
+ on_proc: Callable[[subprocess.Popen | None], None],
+) -> tuple[int, str, str]:
+ """Run to the end; killed on timeout or cancel, which raise OutputInvalid
+ ("timeout", "cancelled"). Returns (returncode, stdout, stderr)."""
+ proc = subprocess.Popen( # noqa: S603 -- list args, trusted ffmpeg/ffprobe
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ on_proc(proc)
+ stop = threading.Event()
+ why: list[str] = []
+
+ def watchdog() -> None:
+ deadline = time.monotonic() + timeout
+ while not stop.wait(0.25):
+ if cancelled() or time.monotonic() > deadline:
+ why.append("cancelled" if cancelled() else "timeout")
+ if proc.poll() is None:
+ proc.kill()
+ return
+
+ guard = threading.Thread(target=watchdog, daemon=True)
+ guard.start()
+ try:
+ out, err = proc.communicate()
+ finally:
+ stop.set()
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ if why:
+ raise OutputInvalid(why[0])
+ return proc.returncode, out, err
+
+
+def validate_output(
+ path: Path,
+ width: int,
+ height: int,
+ fps: int,
+ duration: float,
+ *,
+ cancelled: Callable[[], bool] = lambda: False,
+ on_proc: Callable[[subprocess.Popen | None], None] = lambda p: None,
+) -> None:
+ """Raise OutputInvalid unless `path` is the H.264 + audio MP4 that was
+ asked for and decodes cleanly from end to end. Blocking."""
+ try:
+ size = path.stat().st_size
+ except OSError:
+ raise OutputInvalid("missing") from None
+ if size < max(4096, duration * 1000):
+ raise OutputInvalid("filesize", f"{size} bytes")
+ if size > size_cap_bytes(width, height, fps, duration):
+ raise OutputInvalid("filesize", f"{size} bytes")
+ rc, out, _err = _run_watched(
+ [
+ ffprobe_executable(),
+ "-v",
+ "error",
+ "-show_entries",
+ "stream=codec_type,codec_name,width,height,r_frame_rate,duration:format=duration",
+ "-of",
+ "json",
+ str(path),
+ ],
+ TIMEOUT_VIDEO_ENCODER_PROBE,
+ cancelled,
+ on_proc,
+ )
+ if rc != 0:
+ raise OutputInvalid("probe")
+ try:
+ info = json.loads(out or "{}")
+ except ValueError:
+ raise OutputInvalid("probe") from None
+ streams = info.get("streams") or []
+ video = next((s for s in streams if s.get("codec_type") == "video"), None)
+ if video is None or video.get("codec_name") != "h264":
+ raise OutputInvalid("codec", str(video and video.get("codec_name")))
+ if (video.get("width"), video.get("height")) != (width, height):
+ raise OutputInvalid("size", f"{video.get('width')}x{video.get('height')}")
+ if abs(_fraction(video.get("r_frame_rate")) - fps) > 0.01:
+ raise OutputInvalid("fps", str(video.get("r_frame_rate")))
+ if not any(s.get("codec_type") == "audio" for s in streams):
+ raise OutputInvalid("audio")
+ got = _fraction(video.get("duration") or (info.get("format") or {}).get("duration"))
+ if abs(got - duration) > max(VIDEO_VALIDATE_DURATION_TOL_SEC, 2.0 / fps):
+ raise OutputInvalid("duration", f"{got:.3f} s of {duration:.3f} s")
+ rc, _out, err = _run_watched(
+ [ffmpeg_executable(), "-nostdin", "-v", "error", "-i", str(path), "-f", "null", "-"],
+ max(TIMEOUT_VIDEO_VALIDATE_MIN, duration * VIDEO_VALIDATE_SEC_PER_SEC),
+ cancelled,
+ on_proc,
+ )
+ if rc != 0 or err.strip():
+ raise OutputInvalid("decode", clean_error_line(err))
diff --git a/app/pipeline/vocal_split.py b/app/pipeline/vocal_split.py
index a3d1ebf8..8cb93ea3 100644
--- a/app/pipeline/vocal_split.py
+++ b/app/pipeline/vocal_split.py
@@ -15,21 +15,36 @@
import json
import logging
import os
+import shutil
import subprocess
import sys
import threading
import time
+import uuid
from pathlib import Path
-from app.core.config import TIMEOUT_VOCAL_SPLIT
+from app.core.config import TIMEOUT_VOCAL_SPLIT, VOCAL_SPLIT_WATCHDOG_POLL_SEC
from app.core.models import Job
from app.core.registry import set_proc
from app.core.settings import get_demucs_device
+from app.core.stem_files import decoded_wav, stem_path
+from app.core.vocal_split_outcome import CANCELLED, CODES, TIMEOUT, WORKER_FAILED
from app.pipeline.errors import SeparationError
+from app.pipeline.vocal_split_status import failure_code
logger = logging.getLogger("stemdeck.pipeline")
+class VocalSplitError(SeparationError):
+ """A split that did not make its two stems, with the reason as one of
+ app.core.vocal_split_outcome.CODES (#832). The message and tail are for
+ the log and vocal_split_error.txt; only the code reaches a client."""
+
+ def __init__(self, message: str, *, code: str, **kwargs) -> None:
+ super().__init__(message, **kwargs)
+ self.code = code if code in CODES else WORKER_FAILED
+
+
def _spawn_cmd(device: str, vocals_path: Path, out_dir: Path) -> list[str]:
return [
sys.executable,
@@ -44,15 +59,58 @@ def _spawn_cmd(device: str, vocals_path: Path, out_dir: Path) -> list[str]:
def split_vocals(job: Job, stems_dir: Path) -> list[str]:
"""Run the karaoke model on stems/vocals.wav, producing lead_vocals.wav +
backing_vocals.wav in stems_dir. Returns the two new stem names on
- success. Raises SeparationError (carrying the stderr tail) on failure.
+ success. Raises VocalSplitError (carrying the stderr tail and the reason
+ code) on failure.
Best-effort by design: the caller (the vocal-split API endpoint) is
responsible for catching failures and leaving the job's base stems
untouched -- this function never mutates anything but stems_dir's
contents, and never touches vocals.wav itself."""
- vocals_path = stems_dir / "vocals.wav"
+ vocals_path = stem_path(stems_dir, "vocals")
if not vocals_path.is_file():
- raise SeparationError("vocals.wav not found -- job has no vocals stem to split")
+ raise VocalSplitError(
+ "vocals.wav not found -- job has no vocals stem to split", code=WORKER_FAILED
+ )
+ # The worker writes into the dot temp folder `.vocal_split..tmp` beside
+ # the stems, and the pair moves in only once both are whole: a kill or a
+ # failure never leaves a half-written lead_vocals.wav that reads as a stem.
+ work = stems_dir / f".vocal_split.{uuid.uuid4().hex}.tmp"
+ # audio-separator gets a WAV whatever the song keeps (#783): an MP3 vocals
+ # stem is decoded by ffmpeg first, so the model reads exactly the samples
+ # every other reader does.
+ try:
+ work.mkdir()
+ with decoded_wav(vocals_path) as wav_in:
+ names = _split(job, wav_in, work)
+ return _move_in(work, stems_dir, names)
+ except SeparationError:
+ raise
+ except RuntimeError as exc:
+ raise VocalSplitError("could not read the vocals stem", code=WORKER_FAILED) from exc
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def _move_in(work: Path, stems_dir: Path, names: list[str]) -> list[str]:
+ """Move the pair from `work` into the stems. A move that fails takes back
+ the one already made. A kill between the two renames leaves a lead alone,
+ which a song rebuilt from its folder does not count as split
+ (registry._rebuild_from_folder)."""
+ placed: list[Path] = []
+ try:
+ for name in names:
+ final = stems_dir / f"{name}.wav"
+ os.replace(work / f"{name}.wav", final)
+ placed.append(final)
+ except OSError as exc:
+ for final in placed:
+ final.unlink(missing_ok=True)
+ raise VocalSplitError("could not move the split stems in", code=WORKER_FAILED) from exc
+ return names
+
+
+def _split(job: Job, vocals_path: Path, out_dir: Path) -> list[str]:
+ """split_vocals on a WAV vocals file, writing the pair into `out_dir`."""
device = get_demucs_device()
env = os.environ.copy()
@@ -74,29 +132,37 @@ def split_vocals(job: Job, stems_dir: Path) -> list[str]:
except ModuleNotFoundError:
pass
- proc = subprocess.Popen(
- _spawn_cmd(device, vocals_path, stems_dir),
- stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
- # utf-8/replace explicitly. text=True alone decodes with the locale
- # encoding, which on Windows is cp1252, and a single byte outside it
- # in a child's output kills the whole job with a UnicodeDecodeError
- # ("'charmap' codec can't decode byte 0x8f"). This is diagnostic text:
- # a byte we cannot read is never a reason to fail a separation.
- text=True,
- encoding="utf-8",
- errors="replace",
- bufsize=1,
- env=env,
- )
+ try:
+ proc = subprocess.Popen(
+ _spawn_cmd(device, vocals_path, out_dir),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ # utf-8/replace explicitly. text=True alone decodes with the locale
+ # encoding, which on Windows is cp1252, and a single byte outside it
+ # in a child's output kills the whole job with a UnicodeDecodeError
+ # ("'charmap' codec can't decode byte 0x8f"). This is diagnostic text:
+ # a byte we cannot read is never a reason to fail a separation.
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ bufsize=1,
+ env=env,
+ )
+ except OSError as exc:
+ raise VocalSplitError(
+ f"vocal split could not start: {exc}", code=WORKER_FAILED, device=device
+ ) from exc
set_proc(job.id, proc)
tail: list[str] = []
last_output = [time.monotonic()]
done_evt = threading.Event()
+ stalled = threading.Event()
+ # The code the worker gave with its error line, when it gave one.
+ worker_code: list[str | None] = [None]
def _watchdog() -> None:
- while not done_evt.wait(timeout=30):
+ while not done_evt.wait(timeout=VOCAL_SPLIT_WATCHDOG_POLL_SEC):
if proc.poll() is not None:
return
if time.monotonic() - last_output[0] > TIMEOUT_VOCAL_SPLIT:
@@ -105,6 +171,7 @@ def _watchdog() -> None:
TIMEOUT_VOCAL_SPLIT,
job.id,
)
+ stalled.set()
proc.terminate()
return
@@ -127,6 +194,12 @@ def _watchdog() -> None:
msg = json.loads(msg)
except json.JSONDecodeError:
pass
+ # {"message", "code"} from this worker; a plain string from
+ # one that predates the code.
+ if isinstance(msg, dict):
+ code = msg.get("code")
+ worker_code[0] = code if isinstance(code, str) else None
+ msg = msg.get("message", "")
tail.append(str(msg))
break
tail.append(line)
@@ -148,9 +221,25 @@ def _watchdog() -> None:
detail = "\n".join(tail[-15:]) if tail else "(no stderr captured)"
logger.warning("[%s] vocal split failed: %s", job.id, detail)
last = tail[-1] if tail else "vocal split failed"
- raise SeparationError(f"vocal split failed: {last}", tail=tail[-40:], device=device)
+ # A cancel and the watchdog both end the worker from outside, so what
+ # it last wrote says nothing about why it stopped.
+ if job.cancel_requested:
+ code = CANCELLED
+ elif stalled.is_set():
+ code = TIMEOUT
+ elif worker_code[0] is not None:
+ code = worker_code[0]
+ else:
+ # A worker that died without its error line (a crash, the OOM
+ # killer), or one too old to send a code: judged by its words.
+ code = failure_code("unknown", text="\n".join(tail))
+ raise VocalSplitError(
+ f"vocal split failed: {last}", code=code, tail=tail[-40:], device=device
+ )
for name in ("lead_vocals", "backing_vocals"):
- if not (stems_dir / f"{name}.wav").is_file():
- raise SeparationError(f"vocal split did not produce {name}.wav", device=device)
+ if not (out_dir / f"{name}.wav").is_file():
+ raise VocalSplitError(
+ f"vocal split did not produce {name}.wav", code=WORKER_FAILED, device=device
+ )
return ["lead_vocals", "backing_vocals"]
diff --git a/app/pipeline/vocal_split_status.py b/app/pipeline/vocal_split_status.py
new file mode 100644
index 00000000..7f6a4c03
--- /dev/null
+++ b/app/pipeline/vocal_split_status.py
@@ -0,0 +1,210 @@
+"""Whether this install can make Lead + Backing, and why a split failed (#832).
+
+Two jobs, both small:
+
+- ``failure_code`` turns an exception, or the text a worker left behind, into
+ one of the codes in app.core.vocal_split_outcome. The worker calls it where
+ it knows which step failed; the parent calls it on whatever text it has
+ when the worker died without saying.
+
+- The capability: a failed setup warmup (``WARMUP_FAILED vocal_split ...``)
+ used to be written to setup.log and nowhere else, so the first a person
+ learnt of it was choosing Lead + Backing and getting one Vocals stem. The
+ warmup and every real split now record their outcome in a small file in
+ the data folder, and /api/health reports it, the way it reports an OGG
+ encoder that is missing (#824).
+
+Only a missing dependency, or a platform the split is not built for (Intel
+macOS), makes the split unavailable. A missing dependency cannot fix itself
+on this install: the next attempt fails the same way until StemDeck is
+updated or repaired. A model that did not download can still download on the
+next attempt, and memory, a crash or a stall belong to one run, not to the
+install, so none of those switch anything off.
+
+A recorded failure is tied to the versions of the packages the split loads.
+When those change (an update, or the startup repair that swaps a broken
+torchvision for the right one, #778) the record no longer describes this
+install and is ignored, so a fixed install is never left switched off.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import platform
+import sys
+import threading
+import time
+import uuid
+from importlib import metadata
+
+from app.core.config import DATA_DIR
+from app.core.vocal_split_outcome import (
+ DEPENDENCY_MISSING,
+ MODEL_UNAVAILABLE,
+ OUT_OF_MEMORY,
+ UNSUPPORTED_PLATFORM,
+ WORKER_FAILED,
+)
+from app.pipeline.errors import classify_failure
+
+logger = logging.getLogger("stemdeck.pipeline")
+
+# The packages the split imports, by distribution name. A change in any of
+# them is a different install as far as a recorded failure is concerned.
+_PACKAGES = ("torch", "torchvision", "onnxruntime", "onnx", "onnx2torch", "audio-separator")
+
+# Lowercased substrings that name a missing or broken dependency: a module
+# that is not there, a shared library that would not load, or a torchvision
+# built for another torch (#778). Not onnxruntime or onnx2torch by name: they
+# read the model file, so they are named in the error a damaged model gives,
+# and that is the model, not the install.
+_DEPENDENCY_PATTERNS = (
+ "no module named",
+ "cannot open shared object",
+ "dll load failed",
+ "library not loaded",
+ "undefined symbol",
+ "symbol not found",
+ "torchvision",
+)
+
+# The model could not be fetched, or what was fetched is not a model.
+_MODEL_PATTERNS = (
+ "failed to download",
+ "response code",
+ "max retries exceeded",
+ "connection",
+ "timed out",
+ "name or service not known",
+ "nodename nor servname",
+ "temporary failure in name resolution",
+ "certificate",
+ "ssl",
+ "unsupported model file",
+ "not found in supported model files",
+)
+
+_status_path = DATA_DIR / "vocal-split-status.json"
+_lock = threading.Lock()
+_state: dict | None = None
+_fingerprint_cache: dict[str, str | None] | None = None
+
+
+def _unsupported_platform() -> bool:
+ """Intel macOS, where audio-separator is not installed (pyproject.toml)."""
+ return sys.platform == "darwin" and platform.machine() == "x86_64"
+
+
+def failure_code(phase: str, exc: BaseException | None = None, text: str = "") -> str:
+ """The code for a failure in ``phase`` ("import", "load", "separate", or
+ "unknown" when the caller cannot tell), from the exception or its text."""
+ if _unsupported_platform():
+ return UNSUPPORTED_PLATFORM
+ words = f"{type(exc).__name__}: {exc}" if exc is not None else text
+ low = words.lower()
+ if isinstance(exc, MemoryError) or classify_failure(low) == "out-of-memory":
+ return OUT_OF_MEMORY
+ if isinstance(exc, ImportError) or "importerror" in low or "modulenotfounderror" in low:
+ return DEPENDENCY_MISSING
+ if any(p in low for p in _DEPENDENCY_PATTERNS):
+ return DEPENDENCY_MISSING
+ if phase == "import":
+ return DEPENDENCY_MISSING
+ if phase == "load" or any(p in low for p in _MODEL_PATTERNS):
+ return MODEL_UNAVAILABLE
+ return WORKER_FAILED
+
+
+def _fingerprint() -> dict[str, str | None]:
+ """{package: installed version or None}, read from package metadata only.
+ Nothing is imported, so this stays cheap enough for a health poll, and it
+ is read once per process: the packages do not change under a running
+ backend."""
+ global _fingerprint_cache
+ if _fingerprint_cache is None:
+ found: dict[str, str | None] = {}
+ for name in _PACKAGES:
+ try:
+ found[name] = metadata.version(name)
+ except metadata.PackageNotFoundError:
+ found[name] = None
+ except Exception:
+ logger.warning("could not read the version of %s", name, exc_info=True)
+ found[name] = None
+ _fingerprint_cache = found
+ return _fingerprint_cache
+
+
+def _load() -> dict:
+ global _state
+ if _state is None:
+ try:
+ data = json.loads(_status_path.read_text(encoding="utf-8"))
+ _state = data if isinstance(data, dict) else {}
+ except FileNotFoundError:
+ _state = {}
+ except (OSError, ValueError):
+ logger.warning("vocal split status unreadable; ignoring it", exc_info=True)
+ _state = {}
+ return _state
+
+
+def record(ok: bool, code: str | None = None, *, source: str) -> None:
+ """Remember how the split went on this install.
+
+ A success clears any failure. A failure is kept only when it says
+ something about the install (a dependency or the model); one that belongs
+ to a single run leaves the record as it was. Best-effort: a data folder
+ that cannot be written is logged, never raised."""
+ global _state
+ if not ok and code not in (DEPENDENCY_MISSING, MODEL_UNAVAILABLE):
+ return
+ data = {
+ "ok": bool(ok),
+ "code": None if ok else code,
+ "source": source,
+ "at": time.time(),
+ "packages": _fingerprint(),
+ }
+ with _lock:
+ _state = data
+ try:
+ _status_path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = _status_path.with_name(f"{_status_path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ os.replace(tmp, _status_path)
+ finally:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ logger.warning("could not record the vocal split status", exc_info=True)
+
+
+def capability() -> dict[str, object]:
+ """{"available": bool, "reason": code or None} for /api/health.
+
+ Unavailable on Intel macOS, or when the last outcome on this exact set of
+ packages was a missing dependency. Anything else, a record from other
+ package versions included, reads as available: a split is then tried as
+ it always was."""
+ if _unsupported_platform():
+ return {"available": False, "reason": UNSUPPORTED_PLATFORM}
+ with _lock:
+ data = dict(_load())
+ if (
+ data.get("ok") is False
+ and data.get("code") == DEPENDENCY_MISSING
+ and data.get("packages") == _fingerprint()
+ ):
+ return {"available": False, "reason": DEPENDENCY_MISSING}
+ return {"available": True, "reason": None}
+
+
+def reset_state() -> None:
+ """Forget the in-memory copy (tests)."""
+ global _state, _fingerprint_cache
+ with _lock:
+ _state = None
+ _fingerprint_cache = None
diff --git a/app/pipeline/vocal_split_worker.py b/app/pipeline/vocal_split_worker.py
index c4fb9aa7..aeb0ce99 100644
--- a/app/pipeline/vocal_split_worker.py
+++ b/app/pipeline/vocal_split_worker.py
@@ -13,8 +13,12 @@
Protocol, matching demucs_worker.py's contract so the parent's stderr reader
can be reused: writes progress/log lines to stderr, then exactly one of:
- "@@DONE@@" -- success, both files written
- "@@ERROR@@" -- failure; process exits(1)
+ "@@DONE@@" -- success, both files written
+ "@@ERROR@@" -- failure; process exits(1). The object is
+ {"message": str(exception), "code": one of
+ app.core.vocal_split_outcome.CODES}. The code is decided here, where the
+ step that failed is known (#832): an import that fails is a missing
+ dependency, a model that will not fetch or load is the model.
"""
from __future__ import annotations
@@ -27,25 +31,19 @@
from app.core.process import arm_parent_watchdog
-def _run(device: str, vocals_path: str, out_dir: str) -> None:
- if device == "cpu":
- # audio-separator/onnxruntime pick their execution provider mostly on
- # their own; hiding CUDA devices is the reliable way to force the CPU
- # provider when the caller explicitly resolved to "cpu" (matches the
- # separation stage's own device policy in app.core.settings).
- os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
-
+def _import_separator():
+ """The audio-separator class. Where a missing or broken dependency shows."""
from audio_separator.separator import Separator
+ return Separator
+
+
+def _load(separator) -> None:
+ """Fetch the model if it is not cached yet, and load it. The architecture
+ module, and with it onnx2torch and torchvision (#778), is imported here."""
from app.core.config import MODELS_DIR
from app.core.model_cache import load_or_heal, vocal_split_artifacts
- separator = Separator(
- log_level=40, # logging.ERROR -- only the lines we emit ourselves matter
- model_file_dir=str(MODELS_DIR / "audio-separator"),
- output_dir=out_dir,
- output_format="WAV",
- )
# This is the only path Docker ever takes: there is no warmup step there, so
# the model is fetched here on first use. A download cut short leaves a file
# that audio-separator will keep accepting as present and keep failing to
@@ -54,6 +52,9 @@ def _run(device: str, vocals_path: str, out_dir: str) -> None:
lambda: separator.load_model(model_filename=VOCAL_SPLIT_MODEL),
lambda: vocal_split_artifacts(MODELS_DIR, VOCAL_SPLIT_MODEL),
)
+
+
+def _separate(separator, vocals_path: str) -> None:
# Karaoke-family models label their two outputs "Vocals" (the lead vocal
# that survived a second isolation pass) and "Instrumental" (everything in
# the input that wasn't lead vocal -- since the input here is already an
@@ -65,6 +66,45 @@ def _run(device: str, vocals_path: str, out_dir: str) -> None:
)
+def _run(device: str, vocals_path: str, out_dir: str, phase: list[str]) -> None:
+ """Run the split. ``phase[0]`` names the step in progress, so the caller
+ can say which one failed."""
+ if device == "cpu":
+ # audio-separator/onnxruntime pick their execution provider mostly on
+ # their own; hiding CUDA devices is the reliable way to force the CPU
+ # provider when the caller explicitly resolved to "cpu" (matches the
+ # separation stage's own device policy in app.core.settings).
+ os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
+
+ phase[0] = "import"
+ separator_cls = _import_separator()
+
+ from app.core.config import MODELS_DIR
+
+ phase[0] = "load"
+ separator = separator_cls(
+ log_level=40, # logging.ERROR -- only the lines we emit ourselves matter
+ model_file_dir=str(MODELS_DIR / "audio-separator"),
+ output_dir=out_dir,
+ output_format="WAV",
+ )
+ _load(separator)
+ phase[0] = "separate"
+ _separate(separator, vocals_path)
+
+
+def _error_line(phase: str, exc: BaseException) -> str:
+ try:
+ from app.pipeline.vocal_split_status import failure_code
+
+ code = failure_code(phase, exc)
+ except Exception:
+ # The classifier is a nicety. Failing to load it must not cost the
+ # parent the error itself.
+ code = "worker_failed"
+ return "@@ERROR@@" + json.dumps({"message": str(exc), "code": code})
+
+
def main() -> None:
# A Force-Quit of the app otherwise orphans this process holding the GPU:
# onnxruntime reads nothing from stdin mid-inference, so EOF never arrives
@@ -75,10 +115,11 @@ def main() -> None:
sys.stderr.flush()
sys.exit(1)
device, vocals_path, out_dir = sys.argv[1], sys.argv[2], sys.argv[3]
+ phase = ["import"]
try:
- _run(device, vocals_path, out_dir)
+ _run(device, vocals_path, out_dir, phase)
except Exception as e:
- sys.stderr.write(f"@@ERROR@@{json.dumps(str(e))}\n")
+ sys.stderr.write(_error_line(phase[0], e) + "\n")
sys.stderr.flush()
sys.exit(1)
sys.stderr.write("@@DONE@@\n")
diff --git a/app/pipeline/warmup.py b/app/pipeline/warmup.py
index ac8145bb..dc34d406 100644
--- a/app/pipeline/warmup.py
+++ b/app/pipeline/warmup.py
@@ -8,10 +8,23 @@
steps; Docker has no equivalent step and keeps the pre-existing
lazy-download-on-first-use behavior (see docs/models.md).
-One line per model is printed to stdout so the caller can report per-model
-status: "WARMUP_OK " on success, "WARMUP_FAILED " on
-failure. A model failing to download here is never fatal to setup -- exactly
-like the lazy path it replaces, a missing model degrades only that one
+Lines on stdout tell the caller where it is (#405): "WARMUP_START
+" before each model, then "WARMUP_OK " on success or
+"WARMUP_FAILED " on failure, followed by "WARMUP_OFFLINE "
+when the download host could not be reached or stopped answering. When the
+disk cannot hold the models, the only line is "WARMUP_NO_SPACE
+" and nothing is downloaded.
+
+The steps run inside bounded_downloads (app/core/model_download.py): a
+torch.hub download (the separation and beat models) gets the separation
+model's read timeout and tries, so a dead connection ends it in about a
+minute and a half, and every other socket that sets no timeout of its own
+(Whisper's) gets the read timeout. The Hugging Face hub and audio-separator
+set their own, audio-separator 300 s per read, which the shell's stall limit
+outlasts.
+
+A model failing to download here is never fatal to setup -- exactly like the
+lazy path it replaces, a missing model degrades only that one
feature (e.g. no beat grid, or the karaoke split unavailable until it can
download later) rather than blocking the app from starting.
@@ -31,18 +44,38 @@
from __future__ import annotations
+import http.client
import os
+import shutil
+import socket
import sys
from app.core.config import (
BEAT_MODEL_CHECKPOINT,
DEMUCS_MODEL,
+ MODEL_DOWNLOAD_ATTEMPTS,
MODELS_DIR,
SECTION_MODEL,
+ TIMEOUT_MODEL_DOWNLOAD_READ,
TRANSCRIBE_MODEL_CPU,
TRANSCRIBE_MODEL_GPU,
VOCAL_SPLIT_MODEL,
)
+from app.core.model_download import bounded_downloads
+
+# What each model adds to the data folder, in bytes: measured on a finished
+# install (models/ and cache/huggingface/, 2026-11-03), except Whisper's
+# "small", which is its published size. Only the space check before anything
+# downloads uses these, so a model renamed through the environment is just not
+# counted.
+_DOWNLOAD_BYTES = {
+ "demucs": 54_996_327,
+ "beat_this": 81_058_141,
+ "sections": 11_204_803,
+ "vocal_split": 52_834_731,
+ "turbo": 1_617_941_637,
+ "small": 483_617_219,
+}
def _warm_demucs() -> None:
@@ -90,24 +123,30 @@ def _warm_sections() -> None:
load_pretrained_model(model_name=SECTION_MODEL, device="cpu")
-def _warm_whisper() -> None:
- """The Whisper model the lyrics stage would use here, and only when that
- stage would run: under "auto" only a CUDA install transcribes, and the
- GPU model is 1.6 GB. Last in the list for the same reason, so a slow
- connection costs the other models nothing. A download cut short by the
- setup timeout heals itself: Whisper checks the file's SHA-256 on every
- load and fetches it again when it does not match."""
+def _whisper_model() -> str | None:
+ """The Whisper model the lyrics stage would use here, or None when that
+ stage would not run: under "auto" only a CUDA install transcribes."""
from app.core.settings import get_demucs_device, transcribe_lyrics_enabled
device = get_demucs_device()
if not transcribe_lyrics_enabled(device):
+ return None
+ return TRANSCRIBE_MODEL_GPU if device == "cuda" else TRANSCRIBE_MODEL_CPU
+
+
+def _warm_whisper() -> None:
+ """The GPU model is 1.6 GB, so this is last in the list: a slow
+ connection costs the other models nothing. A download cut short by the
+ setup timeout heals itself: Whisper checks the file's SHA-256 on every
+ load and fetches it again when it does not match."""
+ name = _whisper_model()
+ if name is None:
return
import whisper
from app.pipeline.transcribe import whisper_models_dir
- name = TRANSCRIBE_MODEL_GPU if device == "cuda" else TRANSCRIBE_MODEL_CPU
whisper.load_model(name, device="cpu", download_root=str(whisper_models_dir()))
@@ -120,13 +159,102 @@ def _warm_whisper() -> None:
)
+def _remember_vocal_split(error: Exception | None) -> None:
+ """Keep the vocal split's result where the backend reads it (#832).
+
+ setup.log alone told nobody: the Vocals choice offered Lead + Backing as
+ usual and the first sign was one Vocals stem. Best-effort like the rest."""
+ try:
+ from app.pipeline import vocal_split_status
+
+ if error is None:
+ vocal_split_status.record(True, source="warmup")
+ else:
+ code = vocal_split_status.failure_code("load", error)
+ vocal_split_status.record(False, code, source="warmup")
+ except Exception as e:
+ print(f"WARMUP_NOTE vocal_split status not recorded: {e}", flush=True)
+
+
+def _free_bytes() -> int:
+ """Free space on the disk MODELS_DIR is on, which may not exist yet."""
+ folder = MODELS_DIR
+ while not folder.exists() and folder != folder.parent:
+ folder = folder.parent
+ return shutil.disk_usage(folder).free
+
+
+def _bytes_needed() -> int:
+ """About what the downloads still need: every model's size, less what
+ MODELS_DIR already holds, so an install that has them all needs nothing.
+
+ It errs towards trying. Whatever else sits in MODELS_DIR counts as
+ present, and the sections model's few MB in the Hugging Face cache are
+ always counted as needed rather than walking a cache the user may share
+ with other tools. A check that passes wrongly costs a download that fails
+ on the full disk with its own error, which the wizard reports; one that
+ fails wrongly would skip every model for nothing. A torch.hub ".partial"
+ is a download cut short, not a model."""
+ total = 0
+ for name, _ in _STEPS:
+ key = _whisper_model() if name == "whisper" else name
+ total += _DOWNLOAD_BYTES.get(key or "", 0)
+ present = sum(
+ path.stat().st_size
+ for path in (MODELS_DIR.rglob("*") if MODELS_DIR.is_dir() else ())
+ if path.is_file() and not path.is_symlink() and path.suffix != ".partial"
+ )
+ return max(0, total - present)
+
+
+def _enough_space() -> bool:
+ try:
+ needed, free = _bytes_needed(), _free_bytes()
+ except Exception as e:
+ # Only a courtesy: a full disk still fails each download with its own
+ # error. Not a reason to download nothing.
+ print(f"WARMUP_NOTE space not checked: {e}", flush=True)
+ return True
+ if needed > free:
+ print(f"WARMUP_NO_SPACE {needed} {free}", flush=True)
+ return False
+ return True
+
+
+def _unreachable(error: BaseException) -> bool:
+ """Whether a download failed because its host could not be reached or
+ stopped answering: no network, no DNS, a refused or dropped connection, a
+ read that timed out. The whole chain is read, since every library wraps
+ these in errors of its own (requests, httpx, the hub, ModelDownloadError)."""
+ seen: set[int] = set()
+ current: BaseException | None = error
+ while current is not None and id(current) not in seen:
+ if isinstance(
+ current, (socket.gaierror, TimeoutError, ConnectionError, http.client.IncompleteRead)
+ ):
+ return True
+ seen.add(id(current))
+ current = current.__cause__ or current.__context__
+ return False
+
+
def main() -> int:
- for name, fn in _STEPS:
- try:
- fn()
- print(f"WARMUP_OK {name}", flush=True)
- except Exception as e:
- print(f"WARMUP_FAILED {name} {e}", flush=True)
+ if not _enough_space():
+ return 0
+ with bounded_downloads(TIMEOUT_MODEL_DOWNLOAD_READ, MODEL_DOWNLOAD_ATTEMPTS):
+ for number, (name, fn) in enumerate(_STEPS, start=1):
+ print(f"WARMUP_START {name} {number} {len(_STEPS)}", flush=True)
+ try:
+ fn()
+ print(f"WARMUP_OK {name}", flush=True)
+ if name == "vocal_split":
+ _remember_vocal_split(None)
+ except Exception as e:
+ print(f"WARMUP_FAILED {name} {e}", flush=True)
+ if _unreachable(e):
+ print(f"WARMUP_OFFLINE {name}", flush=True)
+ if name == "vocal_split":
+ _remember_vocal_split(e)
return 0 # best-effort throughout -- see module docstring
diff --git a/build/Dockerfile b/build/Dockerfile
index 1ca32f17..1109a67c 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -66,6 +66,26 @@ COPY static/ ./static/
ARG VERSION=0.0.0
RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300
+# The :rocm image, for AMD GPUs (#825): swap in the ROCm build of torch, which
+# drives an AMD GPU through the same torch.cuda API, so nothing else changes.
+# Empty by default, which keeps the CUDA build the line above installed.
+# torch 2.8.0, the line the desktop app installs for AMD too, on rocm6.4, the
+# newest ROCm index it is published on. --no-deps, so pip touches only these
+# three wheels; the nvidia-* runtime and triton the CUDA build pulled in are
+# then dead weight and removed. The last line fails the build if the swap did
+# not take.
+ARG TORCH_INDEX_URL=
+RUN if [ -n "${TORCH_INDEX_URL}" ]; then \
+ tag="${TORCH_INDEX_URL##*/}" \
+ && pip uninstall -y torch torchaudio torchvision \
+ && pip install --timeout 300 --no-deps \
+ "torch==2.8.0+${tag}" "torchaudio==2.8.0+${tag}" "torchvision==0.23.0+${tag}" \
+ --index-url "${TORCH_INDEX_URL}" \
+ && pip freeze | sed -n 's/^\(nvidia-[^=]*\)==.*/\1/p; s/^\(triton\)==.*/\1/p' \
+ | xargs -r pip uninstall -y \
+ && python -c "import torch, torchaudio; assert torch.version.hip, torch.__version__"; \
+ fi
+
# Drop the ~930 yt-dlp extractors StemDeck can never reach. The container gets
# the same treatment as the desktop bundles and for the same reason: several
# dozen of them are adult sites, named as such, and lazy_extractors.py lists
@@ -82,7 +102,9 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TORCH_HOME=/cache/torch \
- XDG_CACHE_HOME=/cache
+ XDG_CACHE_HOME=/cache \
+ MIOPEN_USER_DB_PATH=/cache/miopen \
+ MIOPEN_CUSTOM_CACHE_DIR=/cache/miopen
# Runtime-only OS deps. ffmpeg has many shared libs (libav*, libsw*,
# libpostproc), so we install via apt rather than copy-pasting binaries
@@ -113,6 +135,8 @@ COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY app/ ./app/
COPY static/ ./static/
+# The AGPL asks for a copy of the license with every copy of the program.
+COPY LICENSE ./LICENSE
# 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
@@ -124,6 +148,8 @@ RUN groupadd --system --gid 1001 app \
&& chown -R app:app /app /cache /jobs
COPY build/docker-entrypoint.sh /entrypoint.sh
+# Next to the entrypoint, which finds it by its own directory (#994).
+COPY build/docker-reown.sh /docker-reown.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
diff --git a/build/docker-compose.yml b/build/docker-compose.yml
index 2201d457..e1c85939 100644
--- a/build/docker-compose.yml
+++ b/build/docker-compose.yml
@@ -21,6 +21,16 @@ services:
# Demucs model weights (~170 MB) survive container rebuilds.
- stemdeck-cache:/cache
restart: unless-stopped
+ # AMD GPU: build with the ROCm torch (or use ghcr.io/stemdeckapp/stemdeck:rocm)
+ # and hand the container the GPU's device nodes. The host needs only the
+ # amdgpu kernel driver; the ROCm runtime is inside the image. No group_add:
+ # the entrypoint keeps the groups the device nodes belong to.
+ # build:
+ # args:
+ # TORCH_INDEX_URL: https://download.pytorch.org/whl/rocm6.4
+ # devices:
+ # - /dev/kfd
+ # - /dev/dri
volumes:
stemdeck-cache:
diff --git a/build/docker-entrypoint.sh b/build/docker-entrypoint.sh
index 4374a3aa..c80283c6 100644
--- a/build/docker-entrypoint.sh
+++ b/build/docker-entrypoint.sh
@@ -7,14 +7,16 @@ set -e
PUID="${PUID:-1001}"
PGID="${PGID:-1001}"
-# Chown the only paths the app writes to before dropping privileges:
+# Own 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
+# still import and run them. Re-owning is also what fixes a bind mount that
+# Docker created as root on first run. docker-reown.sh changes only the entries
+# whose owner or group is wrong, so a library that is already right is read but
+# not rewritten (#994).
+sh "$(dirname -- "$0")/docker-reown.sh" "${PUID}" "${PGID}" /app/jobs /cache || true
touch /app/settings.json 2>/dev/null && chown "${PUID}:${PGID}" /app/settings.json 2>/dev/null || true
# Serve TLS when a certificate is supplied. Appended to the CMD rather than
@@ -34,6 +36,33 @@ if [ -n "${STEMDECK_SSL_CERT:-}" ] && [ -n "${STEMDECK_SSL_KEY:-}" ]; then
set -- "$@" --ssl-certfile "${STEMDECK_SSL_CERT}" --ssl-keyfile "${STEMDECK_SSL_KEY}"
fi
+# An AMD GPU (the :rocm image, run with --device /dev/kfd --device /dev/dri,
+# #825) is reached through device nodes owned by a group, usually render, and
+# often mode 0660. gosu with a numeric UID:GID drops every supplementary group,
+# so the app would lose the GPU it was handed: keep the groups those nodes
+# belong to. Only when /dev/kfd was handed in, so a container without an AMD
+# GPU starts exactly as before. 0 and 65534 are skipped: root's group is not one
+# to hand out, and 65534 is the overflow group of a rootless runtime, which
+# setgroups refuses.
+if [ -e /dev/kfd ]; then
+ gpu_groups=""
+ for dev in /dev/kfd /dev/dri/renderD*; do
+ [ -e "$dev" ] || continue
+ gid="$(stat -c %g "$dev")"
+ case "$gid" in 0|65534) continue ;; esac
+ case ",${gpu_groups}," in
+ *",${gid},"*) ;;
+ *) gpu_groups="${gpu_groups:+${gpu_groups},}${gid}" ;;
+ esac
+ done
+ if [ -n "$gpu_groups" ]; then
+ # setpriv, unlike gosu, leaves HOME at root's; set it the way gosu would.
+ HOME="$(getent passwd "${PUID}" | cut -d: -f6)"
+ export HOME="${HOME:-/}"
+ exec setpriv --reuid="${PUID}" --regid="${PGID}" --groups="${gpu_groups}" -- "$@"
+ fi
+fi
+
# 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}" "$@"
diff --git a/build/docker-reown.sh b/build/docker-reown.sh
new file mode 100644
index 00000000..6ad3d26d
--- /dev/null
+++ b/build/docker-reown.sh
@@ -0,0 +1,201 @@
+#!/bin/sh
+# Give the folders the app writes to the owner the container runs as, changing
+# only the entries that are wrong (#994).
+#
+# Usage: docker-reown.sh UID GID DIR...
+#
+# The entrypoint used to run `chown -R` over the whole library on every start.
+# That rewrote every file even when all of them were already right: slow on a
+# large Unraid share or NAS, and it bumped every file's change time, so backup
+# and sync tools saw the whole library as changed. This walks each tree once,
+# reading only, and chowns just the entries whose owner or group differs. A
+# tree that is already right is left exactly as it was, and the repairs the
+# recursive chown was there for still happen: a bind mount Docker created as
+# root on first run, and files a root process left inside the library.
+#
+# It never leaves the trees it is given, and it runs as root, so this matters:
+# - find -P does not follow a symlink.
+# - chown runs through -execdir, so find changes into each directory itself
+# and chown is handed ./name, never an absolute path. With an absolute path
+# chown would resolve every parent again, and another writer on the share
+# could swap a parent directory for a symlink (to /etc, say) between find
+# listing an entry and chown acting on it.
+# - chown -h changes a link itself, never what it points at.
+# - A DIR that is itself a symlink, or not a directory, is skipped.
+#
+# It writes no file of its own, so it runs the same on a read-only root
+# filesystem with no tmpfs on /tmp (docker run --read-only).
+#
+# Best effort, like the chown it replaces: it always exits 0, so a read-only
+# mount cannot stop the app from starting. It prints one line per DIR saying
+# what it did and how long it took, after the first five lines of any error, so
+# a read-only or root_squash mount names an entry that failed.
+
+# Everything the log shows goes to fd 4, so the walk's own output can be
+# captured on stdout. A plain exec whose redirection fails ends a dash script,
+# so with stdout closed the walk would never run; `command exec` reports the
+# failure instead, and the log then goes nowhere while the walk still runs.
+command exec 4>&1 || exec 4>/dev/null
+log() { echo "stemdeck: ownership: $*" >&4; }
+
+if [ "$#" -lt 3 ]; then
+ log "usage: docker-reown.sh UID GID DIR..."
+ exit 0
+fi
+
+uid="$1"
+gid="$2"
+shift 2
+
+case "$uid" in
+ '' | *[!0-9]*)
+ log "PUID '${uid}' is not a number, ownership left unchanged"
+ exit 0
+ ;;
+esac
+case "$gid" in
+ '' | *[!0-9]*)
+ log "PGID '${gid}' is not a number, ownership left unchanged"
+ exit 0
+ ;;
+esac
+
+# No real id is longer than 20 digits (2^64 has 20), and the stripping below
+# takes time that grows with the square of the length, so a longer value is
+# refused before it runs.
+if [ "${#uid}" -gt 20 ] || [ "${#gid}" -gt 20 ]; then
+ log "PUID or PGID is longer than 20 characters, ownership left unchanged"
+ exit 0
+fi
+
+# 099 is 99 to chown and find, but stat prints 99, and the failure check below
+# compares stat's text. Strip leading zeros, keeping a lone 0.
+strip_zeros() {
+ v="$1"
+ while :; do
+ case "$v" in
+ 0?*) v="${v#0}" ;;
+ *) break ;;
+ esac
+ done
+ printf '%s' "$v"
+}
+uid="$(strip_zeros "$uid")"
+gid="$(strip_zeros "$gid")"
+
+# find refuses -execdir while PATH holds a relative or empty entry, because the
+# command would then be looked up in each directory it visits. Keep only the
+# absolute entries, in their order; with none left, use the system default.
+# set -f: the split must not expand a * or ? in an entry into other paths.
+safe_path=""
+old_ifs="$IFS"
+IFS=:
+set -f
+for p in $PATH; do
+ case "$p" in
+ /*) safe_path="${safe_path:+${safe_path}:}${p}" ;;
+ esac
+done
+set +f
+IFS="$old_ifs"
+PATH="${safe_path:-/usr/sbin:/usr/bin:/sbin:/bin}"
+export PATH
+
+# Run by -execdir with a batch of ./name entries from one directory. When chown
+# reports a failure, find out how many entries it left wrong and print one f
+# for each, counted with find's own marks, so the count is real without giving
+# up -execdir's batching on the success path. chown's error lines go to find's
+# stderr.
+#
+# One stat for the whole batch, not one per entry: on a root_squash or
+# read-only share every chown fails on every start, and a stat per entry made
+# 100k entries take minutes before the app could start. stat without -L reads
+# a link itself, never its target. It prints one uid:gid line per entry it can
+# read, digits and a colon only, so no file name reaches the loop and the split
+# cannot glob; an entry it cannot read prints nothing and stays counted as
+# failed. The f loop is builtins only.
+# Single-quoted on purpose: the batch shell expands these, not this one.
+# shellcheck disable=SC2016
+batch='chown -h -- "$REOWN_OWNER" "$@" && exit 0
+n=$#
+for o in $(stat -c %u:%g -- "$@" 2>/dev/null); do
+ [ "$o" = "$REOWN_OWNER" ] && n=$((n - 1))
+done
+while [ "$n" -gt 0 ]; do printf f; n=$((n - 1)); done
+exit 1'
+REOWN_OWNER="${uid}:${gid}"
+export REOWN_OWNER
+
+# Reads find's and chown's stderr. Shows the first five lines in the log and
+# says how many more there were; prints e when there was any. sed does the
+# counting, so a library where every entry fails costs one pass, not one shell
+# loop per line.
+cap_errors() {
+ sed -n -e '1,5s/^/stemdeck: ownership: /p' -e '$=' | {
+ count=0
+ while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in
+ 'stemdeck: ownership: '*) printf '%s\n' "$line" >&4 ;;
+ *[!0-9]* | '') ;;
+ *) count="$line" ;;
+ esac
+ done
+ if [ "$count" -gt 5 ]; then
+ log " ($((count - 5)) more error lines not shown)"
+ fi
+ if [ "$count" -gt 0 ]; then printf e; fi
+ }
+}
+
+count_of() { # count_of
+ n="$(printf '%s' "$2" | tr -cd "$1" | wc -c)"
+ echo $((n))
+}
+
+# Milliseconds since the epoch. GNU date prints %N; one that cannot falls back
+# to whole seconds rather than breaking the arithmetic.
+now_ms() {
+ t="$(date +%s%N 2>/dev/null)"
+ case "$t" in
+ '' | *[!0-9]*) t="$(date +%s)000000000" ;;
+ esac
+ echo $((t / 1000000))
+}
+
+for dir in "$@"; do
+ if [ -L "$dir" ] || [ ! -d "$dir" ]; then
+ log "${dir} is not a directory, skipped"
+ continue
+ fi
+
+ start="$(now_ms)"
+ # One character per event, all on the captured stdout: x for an entry that
+ # was wrong and handed to chown, c for one already right, f for one chown
+ # left wrong, e when anything was written to stderr. Counting characters,
+ # not lines, keeps a file name with a newline in it from skewing the count.
+ # find's stderr goes through cap_errors; its stdout goes to fd 3, the
+ # capture.
+ marks="$( { find -P "$dir" \( ! -user "$uid" -o ! -group "$gid" \) \
+ -execdir sh -c "$batch" reown {} + -printf x -o -printf c \
+ 2>&1 1>&3 3>&- 4>&- | cap_errors 3>&-; } 3>&1)"
+ took=$(($(now_ms) - start))
+
+ wrong="$(count_of x "$marks")"
+ right="$(count_of c "$marks")"
+ failed="$(count_of f "$marks")"
+ errors="$(count_of e "$marks")"
+ total=$((wrong + right))
+ changed=$((wrong - failed))
+
+ if [ "$total" -eq 0 ]; then
+ log "${dir} could not be checked, ownership left unchanged"
+ elif [ "$failed" -gt 0 ] || [ "$errors" -gt 0 ]; then
+ log "${dir}: re-owned ${changed}, failed ${failed} of ${total} entries to ${uid}:${gid} in ${took} ms, errors above"
+ elif [ "$wrong" -eq 0 ]; then
+ log "${dir} already owned by ${uid}:${gid}, nothing changed (${total} entries checked in ${took} ms)"
+ else
+ log "${dir}: re-owned ${changed} of ${total} entries to ${uid}:${gid} in ${took} ms"
+ fi
+done
+
+exit 0
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index aa7a0823..1d99537e 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "stemdeck-desktop",
"version": "0.1.0-alpha.0",
+ "license": "AGPL-3.0-only",
"devDependencies": {
"@tauri-apps/cli": "^2.0.0"
}
diff --git a/desktop/package.json b/desktop/package.json
index 3154a7d9..f773aa9a 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -2,6 +2,7 @@
"name": "stemdeck-desktop",
"version": "0.0.0",
"private": true,
+ "license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 0a8becdf..03a0d687 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -47,15 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
-[[package]]
-name = "arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
-dependencies = [
- "derive_arbitrary",
-]
-
[[package]]
name = "atk"
version = "0.18.2"
@@ -600,17 +591,6 @@ dependencies = [
"serde_core",
]
-[[package]]
-name = "derive_arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "derive_builder"
version = "0.20.2"
@@ -929,6 +909,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
+ "zlib-rs",
]
[[package]]
@@ -2069,6 +2050,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.11.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -3432,6 +3425,10 @@ dependencies = [
"tauri-plugin-store",
"tempfile",
"time",
+ "webkit2gtk",
+ "webview2-com",
+ "windows-core 0.61.2",
+ "windows-sys 0.59.0",
"zip",
"zstd",
]
@@ -4284,6 +4281,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5542,21 +5545,27 @@ dependencies = [
[[package]]
name = "zip"
-version = "2.4.2"
+version = "9.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+checksum = "3f5b58784007ff6e5f280ec82c8596686a3b9b5dd5836c63288d4479f84728b9"
dependencies = [
- "arbitrary",
"crc32fast",
- "crossbeam-utils",
- "displaydoc",
"flate2",
+ "getrandom 0.4.2",
"indexmap 2.14.0",
"memchr",
- "thiserror 2.0.18",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
"zopfli",
]
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
[[package]]
name = "zmij"
version = "1.0.21"
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 154d59f4..79acc74a 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,9 +1,10 @@
[package]
name = "stemdeck"
-# Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169
+# Placeholder: stamped from the git tag at build time (the release workflows and make-app.sh). #169
version = "0.0.0"
description = "StemDeck desktop launcher"
authors = ["StemDeck contributors"]
+license = "AGPL-3.0-only"
edition = "2021"
rust-version = "1.88"
@@ -44,8 +45,28 @@ tauri-plugin-store = "2"
# Certificate validity dates. Already in the tree via rcgen; named here because
# certs.rs uses it directly.
time = "0.3"
-zip = { version = "2", default-features = false, features = ["deflate"] }
+zip = { version = "9", default-features = false, features = ["deflate"] }
zstd = "0.13"
+# Answering the webview's microphone and camera requests for StemDeck's own
+# page (src/mediaperm.rs). All three are already in the tree through wry, at
+# these versions; named here because mediaperm.rs calls them directly.
+[target.'cfg(windows)'.dependencies]
+webview2-com = "0.38"
+windows-core = "0.61"
+# The backend's kill-on-close job object (src/backendjob.rs, #790). Already in
+# the tree at this version; named here because backendjob.rs calls it directly.
+windows-sys = { version = "0.59", features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_System_Diagnostics_ToolHelp",
+ "Win32_System_JobObjects",
+ "Win32_System_Threading",
+] }
+
+[target.'cfg(target_os = "linux")'.dependencies]
+# Pinned exactly, with the feature wry 0.55.1 uses: wry requires =2.0.2.
+webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
+
[dev-dependencies]
tempfile = "3"
diff --git a/desktop/src-tauri/Entitlements.plist b/desktop/src-tauri/Entitlements.plist
new file mode 100644
index 00000000..87f0281e
--- /dev/null
+++ b/desktop/src-tauri/Entitlements.plist
@@ -0,0 +1,19 @@
+
+
+
+
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+
+
diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist
new file mode 100644
index 00000000..0c2e1593
--- /dev/null
+++ b/desktop/src-tauri/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+
+ NSMicrophoneUsageDescription
+ StemDeck records your voice or instrument while you play along with the stems.
+ NSCameraUsageDescription
+ StemDeck records your camera while you play along with the stems, to make a performance video.
+
+
diff --git a/desktop/src-tauri/src/backendjob.rs b/desktop/src-tauri/src/backendjob.rs
new file mode 100644
index 00000000..6d89092f
--- /dev/null
+++ b/desktop/src-tauri/src/backendjob.rs
@@ -0,0 +1,296 @@
+//! Starting the Python backend so that, on Windows, nothing it starts can
+//! outlive StemDeck (#790).
+//!
+//! On Windows `stop_backend` can only kill the backend: there is no SIGTERM,
+//! so the backend's own shutdown, which stops running video renders, never
+//! runs. Measured on Windows 11 with the venv launcher: killing the launcher
+//! takes the real interpreter down with it (the launcher's own job object),
+//! but an ffmpeg render the interpreter had started keeps running as an
+//! orphan, holding its temp file in the take's folder.
+//!
+//! So the backend is started inside a job object that kills every process in
+//! it when its last handle closes. The handle lives in `BackendHandles`; it
+//! closes when the backend is stopped, and the system closes it when
+//! StemDeck exits in any way, a crash or Task Manager included. Processes the
+//! backend starts stay in the job: the job does not allow breakaway, and a
+//! nested job that does (the venv launcher's) cannot take a process out of
+//! one above it that does not.
+//!
+//! The process is created suspended and only resumed once it is in the job,
+//! so the launcher cannot start the interpreter before the job applies.
+//!
+//! The setup children, pip and the model warmup, start the same way, so
+//! closing StemDeck mid-install ends them too; their job closes when they
+//! finish.
+//!
+//! macOS and Linux keep the plain spawn: there `stop_backend` sends SIGTERM
+//! first and the backend's shutdown stops its renders.
+
+use std::io;
+use std::process::{Child, Command};
+
+#[cfg(windows)]
+pub use windows_impl::{spawn_in_job, KillOnCloseJob};
+
+#[cfg(not(windows))]
+pub enum KillOnCloseJob {}
+
+#[cfg(not(windows))]
+pub fn spawn_in_job(cmd: &mut Command) -> io::Result<(Child, Option)> {
+ Ok((cmd.spawn()?, None))
+}
+
+#[cfg(windows)]
+mod windows_impl {
+ use super::*;
+ use std::ffi::c_void;
+ use std::mem::{size_of, zeroed};
+ use std::os::windows::io::AsRawHandle;
+ use std::os::windows::process::CommandExt;
+ use std::ptr::null;
+ use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{
+ CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
+ };
+ use windows_sys::Win32::System::JobObjects::{
+ AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
+ SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+ };
+ use windows_sys::Win32::System::Threading::{
+ OpenThread, ResumeThread, CREATE_NO_WINDOW, CREATE_SUSPENDED, THREAD_SUSPEND_RESUME,
+ };
+
+ /// A job object that kills all of its processes when dropped.
+ pub struct KillOnCloseJob(HANDLE);
+
+ // SAFETY: a job handle is a kernel handle, usable from any thread; it is
+ // only closed once, in Drop.
+ unsafe impl Send for KillOnCloseJob {}
+ // SAFETY: shared use only terminates the job, which Windows allows from
+ // any thread on the same handle at once.
+ unsafe impl Sync for KillOnCloseJob {}
+
+ impl KillOnCloseJob {
+ /// End every process in the job now, while StemDeck keeps running:
+ /// the setup wizard's Skip on the model step (#405).
+ pub fn terminate(&self) -> io::Result<()> {
+ // SAFETY: the handle is owned by `self` and open until Drop.
+ if unsafe { TerminateJobObject(self.0, 1) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+
+ fn new() -> io::Result {
+ // SAFETY: plain Win32 calls; the handle is owned by the returned
+ // value, and `info` is a zeroed plain-data struct of the size given.
+ unsafe {
+ let handle = CreateJobObjectW(null(), null());
+ if handle.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ let job = Self(handle);
+ let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = zeroed();
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ let ok = SetInformationJobObject(
+ job.0,
+ JobObjectExtendedLimitInformation,
+ &info as *const _ as *const c_void,
+ size_of::() as u32,
+ );
+ if ok == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(job)
+ }
+ }
+ }
+
+ impl Drop for KillOnCloseJob {
+ fn drop(&mut self) {
+ // SAFETY: the handle came from CreateJobObjectW and is closed once.
+ unsafe { CloseHandle(self.0) };
+ }
+ }
+
+ /// Spawn without a console window, inside a kill-on-close job. When no
+ /// job can be made the process still starts, as before, without one
+ /// (logged): a missing safety net must not stop the app.
+ pub fn spawn_in_job(cmd: &mut Command) -> io::Result<(Child, Option)> {
+ let job = match KillOnCloseJob::new() {
+ Ok(job) => job,
+ Err(e) => {
+ eprintln!("[stemdeck] no job object for a child process: {e}");
+ cmd.creation_flags(CREATE_NO_WINDOW);
+ return Ok((cmd.spawn()?, None));
+ }
+ };
+ cmd.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
+ let mut child = cmd.spawn()?;
+ // SAFETY: both handles are valid: the job's is owned by `job`, the
+ // process handle by `child`, which has not been waited on.
+ let assigned =
+ unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle() as HANDLE) } != 0;
+ let assign_error = io::Error::last_os_error();
+ // Resumed whether or not it is in the job: a backend left suspended
+ // would only look like a startup that never answers.
+ if let Err(e) = resume_main_thread(child.id()) {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(e);
+ }
+ if !assigned {
+ eprintln!("[stemdeck] child process not in its job object: {assign_error}");
+ return Ok((child, None));
+ }
+ Ok((child, Some(job)))
+ }
+
+ /// Resume the threads of a process created suspended (it has one).
+ fn resume_main_thread(pid: u32) -> io::Result<()> {
+ // SAFETY: plain Win32 calls on handles opened and closed here;
+ // `entry` is a plain-data struct with its size set as the API needs.
+ unsafe {
+ let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
+ if snapshot == INVALID_HANDLE_VALUE {
+ return Err(io::Error::last_os_error());
+ }
+ let mut entry: THREADENTRY32 = zeroed();
+ entry.dwSize = size_of::() as u32;
+ let mut resumed = false;
+ let mut more = Thread32First(snapshot, &mut entry) != 0;
+ while more {
+ if entry.th32OwnerProcessID == pid {
+ let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID);
+ if !thread.is_null() {
+ resumed |= ResumeThread(thread) != u32::MAX;
+ CloseHandle(thread);
+ }
+ }
+ more = Thread32Next(snapshot, &mut entry) != 0;
+ }
+ CloseHandle(snapshot);
+ if resumed {
+ Ok(())
+ } else {
+ Err(io::Error::other("the child process could not be resumed"))
+ }
+ }
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+ use std::process::Stdio;
+ use std::thread;
+ use std::time::{Duration, Instant};
+ use windows_sys::Win32::System::Diagnostics::ToolHelp::{
+ Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
+ };
+ use windows_sys::Win32::System::Threading::{
+ GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
+ };
+
+ const STILL_ACTIVE: u32 = 259;
+
+ fn alive(pid: u32) -> bool {
+ // SAFETY: plain Win32 calls on a handle opened and closed here.
+ unsafe {
+ let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
+ if h.is_null() {
+ return false;
+ }
+ let mut code = 0u32;
+ let ok = GetExitCodeProcess(h, &mut code);
+ CloseHandle(h);
+ ok != 0 && code == STILL_ACTIVE
+ }
+ }
+
+ fn child_of(parent: u32) -> Option {
+ // SAFETY: as in resume_main_thread.
+ unsafe {
+ let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+ if snapshot == INVALID_HANDLE_VALUE {
+ return None;
+ }
+ let mut entry: PROCESSENTRY32W = zeroed();
+ entry.dwSize = size_of::() as u32;
+ let mut found = None;
+ let mut more = Process32FirstW(snapshot, &mut entry) != 0;
+ while more {
+ if entry.th32ParentProcessID == parent {
+ let name = String::from_utf16_lossy(&entry.szExeFile);
+ if name.to_ascii_lowercase().starts_with("ping") {
+ found = Some(entry.th32ProcessID);
+ break;
+ }
+ }
+ more = Process32NextW(snapshot, &mut entry) != 0;
+ }
+ CloseHandle(snapshot);
+ found
+ }
+ }
+
+ fn eventually(mut what: impl FnMut() -> bool) -> bool {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while Instant::now() < deadline {
+ if what() {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ false
+ }
+
+ /// The tree the backend makes: cmd stands for the backend (or the
+ /// venv launcher), the ping it starts for ffmpeg.
+ fn tree() -> Command {
+ let mut cmd = Command::new("cmd");
+ cmd.args(["/d", "/c", "ping -n 120 127.0.0.1"])
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ cmd
+ }
+
+ #[test]
+ fn a_grandchild_dies_when_the_job_closes() {
+ let (mut child, job) = spawn_in_job(&mut tree()).expect("spawned");
+ let job = job.expect("the backend is in a job");
+ let pid = child.id();
+ assert!(
+ eventually(|| child_of(pid).is_some()),
+ "the child was resumed and started its own child"
+ );
+ let grandchild = child_of(pid).expect("grandchild");
+ // Quit as stop_backend does on Windows: the child is killed hard.
+ let _ = child.kill();
+ let _ = child.wait();
+ thread::sleep(Duration::from_millis(300));
+ assert!(
+ alive(grandchild),
+ "a killed parent alone leaves its child running"
+ );
+ drop(job);
+ assert!(
+ eventually(|| !alive(grandchild)),
+ "the job took the grandchild down"
+ );
+ }
+
+ #[test]
+ fn the_backend_is_resumed_and_runs_to_its_end() {
+ let mut cmd = Command::new("cmd");
+ cmd.args(["/d", "/c", "exit 7"]);
+ let (mut child, _job) = spawn_in_job(&mut cmd).expect("spawned");
+ assert!(
+ eventually(|| matches!(child.try_wait(), Ok(Some(_)))),
+ "left suspended"
+ );
+ assert_eq!(child.wait().expect("status").code(), Some(7));
+ }
+ }
+}
diff --git a/desktop/src-tauri/src/dmabuf.rs b/desktop/src-tauri/src/dmabuf.rs
new file mode 100644
index 00000000..50e5a09a
--- /dev/null
+++ b/desktop/src-tauri/src/dmabuf.rs
@@ -0,0 +1,1952 @@
+//! Linux: keep WebKitGTK's DMA-BUF renderer from killing StemDeck at launch (#780).
+//!
+//! On a Wayland session with the NVIDIA driver, WebKitGTK's DMA-BUF renderer
+//! commits a buffer without the explicit-sync timeline the compositor expects,
+//! and the compositor drops the connection ("Missing acquire timeline", GDK
+//! "Error 71"). The process exits before the window shows anything. Setting
+//! `WEBKIT_DISABLE_DMABUF_RENDERER=1` before GTK starts avoids it, and has to
+//! be done by the process itself, because a desktop launcher gives the user no
+//! place to set it.
+//!
+//! Decision order, first match wins:
+//!
+//! 1. `WEBKIT_DISABLE_DMABUF_RENDERER` already in the environment: left alone.
+//! 2. `STEMDECK_WEBKIT_DMABUF=on|off`: `on` keeps the renderer, disables both
+//! automatic rules and forgets everything the crash guard saved; `off`
+//! turns it off. `auto`, unset or an unknown value carry on to the rules
+//! below.
+//! 3. The crash guard (#993). A launch that dies before its window has loaded
+//! and stayed up for a few seconds is an early exit. The dying process
+//! cannot report why (a protocol error ends it with `_exit`, a kill or an
+//! out-of-memory kill gives it no say at all), so one early exit is no
+//! evidence against the renderer: it is counted, and the renderer is tried
+//! again. Only an early exit that had the renderer on is counted, and only
+//! [`EARLY_EXITS_TO_SAVE`] of them in a row save the fallback. A launch
+//! that loads and stays up resets the count. A saved fallback is not for
+//! good: after [`RETRY_AFTER_CLEAN_RUNS`] clean launches under it, or when
+//! the WebKitGTK library or the NVIDIA driver changes, the renderer is
+//! tried again, and one more early exit puts the fallback back.
+//! 4. Wayland (XWayland included) with the NVIDIA driver loaded: off. Per
+//! launch; nothing is saved.
+//!
+//! Everything that decides is a pure function here and is unit-tested on
+//! every platform, with the clock, the files' contents and the environment
+//! passed in. Only the thin shell at the bottom touches the process
+//! environment and the files, and it is Linux-only.
+
+use std::fs;
+use std::io::{self, Write};
+use std::path::Path;
+
+/// Written when the window is about to be built, removed once it has loaded.
+pub const MARKER_FILE: &str = "launch-pending";
+
+/// Early exits in a row, each with the renderer on, that save the fallback.
+/// Two, so a single unrelated death (a kill, a logout, an out-of-memory kill)
+/// changes nothing, while a renderer that kills every launch is off from the
+/// third launch on.
+pub const EARLY_EXITS_TO_SAVE: u32 = 2;
+
+/// Clean launches under a saved fallback before the renderer is tried again.
+/// Doubled after each retry that failed, up to [`MAX_RETRY_DOUBLINGS`] times,
+/// so a machine where the renderer really crashes pays one crash every 10,
+/// then 20, 40 and at most 80 launches, while a fallback saved by bad luck
+/// goes away by itself.
+pub const RETRY_AFTER_CLEAN_RUNS: u32 = 10;
+pub const MAX_RETRY_DOUBLINGS: u32 = 3;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+ Auto,
+ On,
+ Off,
+}
+
+/// `None` or blank means auto. An unknown value is returned as `Err` so the
+/// caller can log it; it is then treated as auto.
+pub fn parse_mode(raw: Option<&str>) -> Result {
+ let Some(raw) = raw else {
+ return Ok(Mode::Auto);
+ };
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "" | "auto" => Ok(Mode::Auto),
+ "on" => Ok(Mode::On),
+ "off" => Ok(Mode::Off),
+ _ => Err(raw.to_string()),
+ }
+}
+
+/// A Wayland session. `WAYLAND_DISPLAY` stays set when GTK is pushed onto
+/// XWayland with `GDK_BACKEND=x11`, so that case counts too: the reporter's
+/// XWayland attempt failed in the same renderer, in a different way.
+pub fn is_wayland(wayland_display: Option<&str>, session_type: Option<&str>) -> bool {
+ wayland_display.is_some_and(|v| !v.trim().is_empty())
+ || session_type.is_some_and(|v| v.trim().eq_ignore_ascii_case("wayland"))
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Marker {
+ pub pid: u32,
+ /// Unix seconds. For whoever reads the file; not used to decide.
+ pub started: u64,
+ /// The kernel boot id, when it could be read.
+ pub boot_id: Option,
+ /// That launch ran with the DMA-BUF renderer on. A marker without the
+ /// line counts as on, as every launch before #993 that wrote one did.
+ pub renderer_on: bool,
+}
+
+pub fn format_marker(m: &Marker) -> String {
+ let mut out = format!("pid={}\nstarted={}\n", m.pid, m.started);
+ if let Some(boot) = &m.boot_id {
+ out.push_str(&format!("boot={boot}\n"));
+ }
+ out.push_str(if m.renderer_on {
+ "renderer=on\n"
+ } else {
+ "renderer=off\n"
+ });
+ out
+}
+
+pub fn parse_marker(text: &str) -> Option {
+ let mut pid = None;
+ let mut started = None;
+ let mut boot_id = None;
+ let mut renderer_on = true;
+ for line in text.lines() {
+ match line.split_once('=') {
+ Some(("pid", v)) => pid = v.trim().parse().ok(),
+ Some(("started", v)) => started = v.trim().parse().ok(),
+ Some(("boot", v)) if !v.trim().is_empty() => boot_id = Some(v.trim().to_string()),
+ Some(("renderer", v)) => renderer_on = v.trim() != "off",
+ _ => {}
+ }
+ }
+ Some(Marker {
+ pid: pid.filter(|p| *p != 0)?,
+ started: started?,
+ boot_id,
+ renderer_on,
+ })
+}
+
+/// What the marker left by the previous launch says about it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PreviousLaunch {
+ /// No marker: the last launch reached a loaded window, or quit cleanly.
+ Clean,
+ /// Its process is gone and the window never finished loading.
+ Crashed {
+ /// It had the DMA-BUF renderer on. An early exit with the renderer
+ /// off cannot be the renderer's doing and is never counted.
+ renderer_on: bool,
+ },
+ /// Its process is still alive: another instance, not a crash.
+ StillRunning,
+ /// Unreadable, or from before a reboot: says nothing about the renderer.
+ Stale,
+}
+
+pub fn classify_previous(
+ marker_text: Option<&str>,
+ current_boot: Option<&str>,
+ own_pid: u32,
+ pid_alive: impl Fn(u32) -> bool,
+) -> PreviousLaunch {
+ let Some(text) = marker_text else {
+ return PreviousLaunch::Clean;
+ };
+ let Some(marker) = parse_marker(text) else {
+ return PreviousLaunch::Stale;
+ };
+ // A power cut or a reboot in the first seconds is not a renderer crash.
+ if let (Some(then), Some(now)) = (marker.boot_id.as_deref(), current_boot) {
+ if then != now {
+ return PreviousLaunch::Stale;
+ }
+ }
+ // Only possible after a reboot reused the pid.
+ if marker.pid == own_pid {
+ return PreviousLaunch::Stale;
+ }
+ // A reused pid reads as alive and the crash goes unnoticed: the safe way
+ // round, since a miss costs one more crash and never a wrong fallback.
+ if pid_alive(marker.pid) {
+ return PreviousLaunch::StillRunning;
+ }
+ PreviousLaunch::Crashed {
+ renderer_on: marker.renderer_on,
+ }
+}
+
+/// The fallback the crash guard saved.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Fallback {
+ /// Unix seconds when it was saved. For whoever reads the file.
+ pub since: u64,
+ /// Launches under it that loaded and stayed up.
+ pub clean_runs: u32,
+ /// The WebKitGTK library and NVIDIA driver it was saved under.
+ pub fingerprint: Option,
+}
+
+/// Everything the crash guard keeps between launches, in one file.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct GuardState {
+ /// Early exits in a row, each with the renderer on.
+ pub early_exits: u32,
+ /// The renderer is being tried again after a saved fallback.
+ pub retrying: bool,
+ /// Retries in a row that ended in the fallback again.
+ pub failed_retries: u32,
+ pub fallback: Option,
+}
+
+pub fn format_state(s: &GuardState) -> String {
+ let mut out = format!(
+ "early_exits={}\nretrying={}\nfailed_retries={}\n",
+ s.early_exits,
+ u8::from(s.retrying),
+ s.failed_retries
+ );
+ if let Some(f) = &s.fallback {
+ out.push_str(&format!(
+ "fallback_since={}\nfallback_clean_runs={}\n",
+ f.since, f.clean_runs
+ ));
+ if let Some(fp) = &f.fingerprint {
+ out.push_str(&format!("fallback_fingerprint={}\n", one_line(fp)));
+ }
+ }
+ out
+}
+
+/// `Err` names the first line that does not parse. Unknown keys are skipped,
+/// so a later version can add one without an older one calling it corrupt.
+pub fn parse_state(text: &str) -> Result {
+ fn num(key: &str, v: &str) -> Result {
+ v.trim()
+ .parse()
+ .map_err(|_| format!("{key}={} is not a number", v.trim()))
+ }
+ let mut s = GuardState::default();
+ let mut since = None;
+ let mut clean_runs = 0;
+ let mut fingerprint = None;
+ for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
+ let Some((key, v)) = line.split_once('=') else {
+ return Err(format!("\"{line}\" is not key=value"));
+ };
+ match key.trim() {
+ "early_exits" => s.early_exits = num(key, v)?,
+ "failed_retries" => s.failed_retries = num(key, v)?,
+ "retrying" => {
+ s.retrying = match v.trim() {
+ "0" => false,
+ "1" => true,
+ other => return Err(format!("retrying={other} is not 0 or 1")),
+ }
+ }
+ "fallback_since" => since = Some(num(key, v)?),
+ "fallback_clean_runs" => clean_runs = num(key, v)?,
+ "fallback_fingerprint" => {
+ fingerprint = Some(v.trim().to_string()).filter(|f| !f.is_empty())
+ }
+ _ => {}
+ }
+ }
+ s.fallback = since.map(|since| Fallback {
+ since,
+ clean_runs,
+ fingerprint,
+ });
+ Ok(s)
+}
+
+/// The state file's text read for this launch: `Err` carries why it was
+/// unreadable, and the guard then starts again from nothing.
+pub fn load_state(text: Option<&str>) -> Result {
+ text.map_or(Ok(GuardState::default()), parse_state)
+}
+
+/// Clean launches a saved fallback waits for, after this many failed retries.
+pub fn retry_after(failed_retries: u32) -> u32 {
+ RETRY_AFTER_CLEAN_RUNS << failed_retries.min(MAX_RETRY_DOUBLINGS)
+}
+
+fn one_line(s: &str) -> String {
+ s.split_whitespace().collect::>().join(" ")
+}
+
+/// What a fallback is tied to: the WebKitGTK library file the process mapped
+/// (its name carries the version, as `libwebkit2gtk-4.1.so.0.13.5`) and the
+/// NVIDIA driver's version line. `None` when neither can be read. Mesa is
+/// left out: it is loaded only later, when WebKit asks for a GPU.
+pub fn fingerprint_from(proc_maps: Option<&str>, nvidia_version: Option<&str>) -> Option {
+ let webkit = proc_maps.and_then(|maps| {
+ maps.lines().find_map(|line| {
+ let path = line.split_whitespace().nth(5)?;
+ let name = path.rsplit('/').next()?;
+ name.starts_with("libwebkit2gtk").then(|| name.to_string())
+ })
+ });
+ let nvidia = nvidia_version
+ .and_then(|t| t.lines().next())
+ .map(one_line)
+ .filter(|l| !l.is_empty());
+ let parts: Vec = [("webkit", webkit), ("nvidia", nvidia)]
+ .into_iter()
+ .filter_map(|(k, v)| v.map(|v| format!("{k}={v}")))
+ .collect();
+ (!parts.is_empty()).then(|| parts.join("; "))
+}
+
+pub struct Inputs<'a> {
+ pub webkit_var: Option<&'a str>,
+ pub mode: Option<&'a str>,
+ pub wayland_display: Option<&'a str>,
+ pub session_type: Option<&'a str>,
+ pub nvidia_driver: bool,
+ pub state: &'a GuardState,
+ pub previous: PreviousLaunch,
+ pub fingerprint: Option<&'a str>,
+ /// Unix seconds.
+ pub now: u64,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Reason {
+ UserSetWebkitVar,
+ ForcedOn,
+ ForcedOff,
+ SavedFallback,
+ EarlyExitsInARow,
+ WaylandNvidia,
+ NoRuleMatched,
+}
+
+impl Reason {
+ pub fn describe(self) -> &'static str {
+ match self {
+ Reason::UserSetWebkitVar => {
+ "WEBKIT_DISABLE_DMABUF_RENDERER was already set; left as is"
+ }
+ Reason::ForcedOn => "STEMDECK_WEBKIT_DMABUF=on; renderer left on, automatic rules off",
+ Reason::ForcedOff => "STEMDECK_WEBKIT_DMABUF=off; renderer turned off",
+ Reason::SavedFallback => "renderer off: saved after earlier launches closed early",
+ Reason::EarlyExitsInARow => {
+ "renderer off: the last launches closed before their window loaded"
+ }
+ Reason::WaylandNvidia => "renderer off: Wayland session with the NVIDIA driver",
+ Reason::NoRuleMatched => "renderer left on: no rule matched",
+ }
+ }
+}
+
+/// One thing the crash guard noticed or changed, for the log.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum GuardEvent {
+ /// The previous launch closed early with the renderer on.
+ EarlyExitCounted {
+ count: u32,
+ },
+ /// The previous launch closed early with the renderer already off.
+ EarlyExitNotCounted,
+ FallbackSaved {
+ retry_after: u32,
+ /// Saved again by the early exit of a retry, not by a fresh row.
+ after_retry: bool,
+ },
+ RetryAfterCleanRuns {
+ clean_runs: u32,
+ },
+ RetryAfterChange {
+ was: String,
+ now: String,
+ },
+ /// `STEMDECK_WEBKIT_DMABUF=on` dropped what the guard had saved.
+ Forgotten,
+}
+
+impl GuardEvent {
+ pub fn describe(&self) -> String {
+ match self {
+ GuardEvent::EarlyExitCounted { count } => {
+ let mut line = format!(
+ "the previous launch closed before its window loaded, with the DMA-BUF \
+ renderer on ({count} of {EARLY_EXITS_TO_SAVE} in a row before it is \
+ turned off)"
+ );
+ if *count < EARLY_EXITS_TO_SAVE {
+ line.push_str(
+ "; one early exit can be anything, so the renderer is tried again",
+ );
+ }
+ line
+ }
+ GuardEvent::EarlyExitNotCounted => "the previous launch closed before its window \
+ loaded, with the DMA-BUF renderer already off; not counted against it"
+ .to_string(),
+ GuardEvent::FallbackSaved {
+ retry_after,
+ after_retry,
+ } => {
+ let why = if *after_retry {
+ "WebKit's DMA-BUF renderer was tried again and StemDeck closed before its \
+ window loaded"
+ .to_string()
+ } else {
+ format!(
+ "StemDeck closed before its window loaded on {EARLY_EXITS_TO_SAVE} \
+ launches in a row with WebKit's DMA-BUF renderer on"
+ )
+ };
+ format!(
+ "{why}, so the renderer is now off. It is tried again after {retry_after} \
+ launches that load cleanly, or when WebKitGTK or the NVIDIA driver \
+ changes. To turn it back on now, start StemDeck once with \
+ STEMDECK_WEBKIT_DMABUF=on."
+ )
+ }
+ GuardEvent::RetryAfterCleanRuns { clean_runs } => format!(
+ "saved DMA-BUF fallback dropped after {clean_runs} clean launches; the \
+ renderer is tried again, and one early exit turns it back off"
+ ),
+ GuardEvent::RetryAfterChange { was, now } => format!(
+ "saved DMA-BUF fallback dropped: the graphics stack changed ({was} -> {now}); \
+ the renderer is tried again, and one early exit turns it back off"
+ ),
+ GuardEvent::Forgotten => {
+ "saved DMA-BUF crash guard state cleared by STEMDECK_WEBKIT_DMABUF=on".to_string()
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Decision {
+ /// Set `WEBKIT_DISABLE_DMABUF_RENDERER=1` for this process.
+ pub disable_dmabuf: bool,
+ pub reason: Reason,
+ /// Write the launch marker for this launch.
+ pub arm_guard: bool,
+ /// The guard's state after this launch's decision, to persist.
+ pub state: GuardState,
+ /// What the guard noticed or changed, each a log line.
+ pub events: Vec,
+ /// An unrecognised STEMDECK_WEBKIT_DMABUF value, to log.
+ pub invalid_mode: Option,
+}
+
+pub fn decide(i: &Inputs) -> Decision {
+ let (mode, invalid_mode) = match parse_mode(i.mode) {
+ Ok(m) => (m, None),
+ Err(raw) => (Mode::Auto, Some(raw)),
+ };
+ let base = Decision {
+ disable_dmabuf: false,
+ reason: Reason::NoRuleMatched,
+ arm_guard: false,
+ state: i.state.clone(),
+ events: Vec::new(),
+ invalid_mode,
+ };
+ if i.webkit_var.is_some() {
+ return Decision {
+ reason: Reason::UserSetWebkitVar,
+ ..base
+ };
+ }
+ match mode {
+ Mode::On => {
+ let forget = *i.state != GuardState::default();
+ return Decision {
+ reason: Reason::ForcedOn,
+ state: GuardState::default(),
+ events: if forget {
+ vec![GuardEvent::Forgotten]
+ } else {
+ Vec::new()
+ },
+ ..base
+ };
+ }
+ Mode::Off => {
+ return Decision {
+ disable_dmabuf: true,
+ reason: Reason::ForcedOff,
+ ..base
+ }
+ }
+ Mode::Auto => {}
+ }
+
+ let mut s = i.state.clone();
+ let mut events = Vec::new();
+ match i.previous {
+ PreviousLaunch::Crashed { renderer_on: true } => {
+ s.early_exits = s.early_exits.saturating_add(1);
+ events.push(GuardEvent::EarlyExitCounted {
+ count: s.early_exits,
+ });
+ }
+ PreviousLaunch::Crashed { renderer_on: false } => {
+ events.push(GuardEvent::EarlyExitNotCounted);
+ }
+ _ => {}
+ }
+
+ if let Some(fb) = &s.fallback {
+ let changed = match (fb.fingerprint.as_deref(), i.fingerprint) {
+ (Some(was), Some(now)) if was != now => Some((was.to_string(), now.to_string())),
+ _ => None,
+ };
+ let due = fb.clean_runs >= retry_after(s.failed_retries);
+ if changed.is_none() && !due {
+ return Decision {
+ disable_dmabuf: true,
+ reason: Reason::SavedFallback,
+ arm_guard: true,
+ state: s,
+ events,
+ ..base
+ };
+ }
+ events.push(match changed {
+ Some((was, now)) => GuardEvent::RetryAfterChange { was, now },
+ None => GuardEvent::RetryAfterCleanRuns {
+ clean_runs: fb.clean_runs,
+ },
+ });
+ // One step short of the fallback: the evidence that saved it
+ // still stands, so one more early exit puts it back.
+ s.fallback = None;
+ s.retrying = true;
+ s.early_exits = EARLY_EXITS_TO_SAVE - 1;
+ } else if s.early_exits >= EARLY_EXITS_TO_SAVE {
+ let after_retry = s.retrying;
+ if after_retry {
+ s.failed_retries = s.failed_retries.saturating_add(1);
+ }
+ s.retrying = false;
+ s.early_exits = 0;
+ s.fallback = Some(Fallback {
+ since: i.now,
+ clean_runs: 0,
+ fingerprint: i.fingerprint.map(str::to_string),
+ });
+ events.push(GuardEvent::FallbackSaved {
+ retry_after: retry_after(s.failed_retries),
+ after_retry,
+ });
+ return Decision {
+ disable_dmabuf: true,
+ reason: Reason::EarlyExitsInARow,
+ arm_guard: true,
+ state: s,
+ events,
+ ..base
+ };
+ }
+
+ let armed = Decision {
+ arm_guard: true,
+ state: s,
+ events,
+ ..base
+ };
+ if i.nvidia_driver && is_wayland(i.wayland_display, i.session_type) {
+ return Decision {
+ disable_dmabuf: true,
+ reason: Reason::WaylandNvidia,
+ ..armed
+ };
+ }
+ armed
+}
+
+/// The guard's state once a launch has loaded its window and stayed up.
+/// With the renderer on, that clears every count against it. With it off,
+/// it says nothing about the renderer, and only moves a saved fallback one
+/// clean launch closer to its retry.
+pub fn after_clean_run(state: &GuardState, renderer_on: bool) -> GuardState {
+ let mut s = state.clone();
+ if renderer_on {
+ s.early_exits = 0;
+ s.retrying = false;
+ s.failed_retries = 0;
+ } else if let Some(fb) = &mut s.fallback {
+ fb.clean_runs = fb.clean_runs.saturating_add(1);
+ }
+ s
+}
+
+/// Temp file and rename, so a crash mid-write never leaves half a marker.
+pub fn write_atomic(dir: &Path, name: &str, contents: &str) -> io::Result<()> {
+ fs::create_dir_all(dir)?;
+ let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
+ let result = (|| {
+ let mut f = fs::File::create(&tmp)?;
+ f.write_all(contents.as_bytes())?;
+ f.sync_all()?;
+ fs::rename(&tmp, dir.join(name))
+ })();
+ if result.is_err() {
+ let _ = fs::remove_file(&tmp);
+ }
+ result
+}
+
+/// Writes the guard's state, or removes the file when there is nothing to
+/// keep, so a machine the guard never had to act on carries no file.
+pub fn store_state(dir: &Path, name: &str, state: &GuardState) -> io::Result<()> {
+ if *state == GuardState::default() {
+ match fs::remove_file(dir.join(name)) {
+ Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
+ _ => Ok(()),
+ }
+ } else {
+ write_atomic(dir, name, &format_state(state))
+ }
+}
+
+/// Removes the marker only when it is this process's own, so one instance
+/// never clears the marker of another that is still starting up.
+pub fn clear_marker_if_ours(dir: &Path, own_pid: u32) -> bool {
+ let path = dir.join(MARKER_FILE);
+ let ours = fs::read_to_string(&path)
+ .ok()
+ .and_then(|t| parse_marker(&t))
+ .is_some_and(|m| m.pid == own_pid);
+ ours && fs::remove_file(&path).is_ok()
+}
+
+/// The crash guard's counts and saved fallback.
+pub const STATE_FILE: &str = "webkit-dmabuf-guard";
+/// The fallback as test builds before #993 saved it, after one early exit.
+/// Never read: one early exit is not enough, so it is dropped.
+pub const LEGACY_FALLBACK_FILE: &str = "webkit-dmabuf-off";
+
+/// The pid in the name of a temp file `write_atomic` leaves when its process
+/// dies between create and rename: `.launch-pending..tmp` or
+/// `.webkit-dmabuf-guard..tmp`. `None` for every other name.
+pub fn stale_tmp_pid(file_name: &str) -> Option {
+ let inner = file_name.strip_prefix('.')?.strip_suffix(".tmp")?;
+ let (base, pid) = inner.rsplit_once('.')?;
+ if base != MARKER_FILE && base != STATE_FILE {
+ return None;
+ }
+ // Exactly what write_atomic writes: ASCII digits, no sign, no leading
+ // zero. `parse` alone would also take "+7" and "007".
+ if pid.is_empty() || pid.starts_with('0') || !pid.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ pid.parse().ok()
+}
+
+/// Removes the temp files of processes that are gone. A live pid's file is
+/// left: that process may be between create and rename right now.
+pub fn sweep_stale_tmp(dir: &Path, own_pid: u32, pid_alive: &dyn Fn(u32) -> bool) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let name = entry.file_name();
+ let Some(pid) = name.to_str().and_then(stale_tmp_pid) else {
+ continue;
+ };
+ if pid != own_pid && !pid_alive(pid) {
+ let _ = fs::remove_file(entry.path());
+ }
+ }
+}
+
+/// How reading the state file went, which decides what may be written back.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StateRead {
+ /// Read and parsed, or missing (no state yet): write back on a change.
+ Read,
+ /// Its content is not a state file (does not parse, not UTF-8): it can
+ /// never be read, so it is replaced.
+ Corrupt,
+ /// The file could not be read at all (permissions, an I/O error). It may
+ /// hold a saved fallback that a later launch can read, so it is left as
+ /// it is: this launch decides without it and writes nothing.
+ Unreadable,
+}
+
+impl StateRead {
+ /// The state should be written, given whether the decision changed it.
+ pub fn write_back(self, changed: bool) -> bool {
+ match self {
+ StateRead::Read => changed,
+ StateRead::Corrupt => true,
+ StateRead::Unreadable => false,
+ }
+ }
+}
+
+/// What the result of reading the state file means for this launch.
+pub fn interpret_state_read(
+ result: io::Result,
+ log: &mut dyn FnMut(&str),
+) -> (GuardState, StateRead) {
+ let text = match result {
+ Ok(t) => Some(t),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => None,
+ Err(e) if e.kind() == io::ErrorKind::InvalidData => {
+ log(&format!(
+ "DMA-BUF crash guard state is not text ({e}); starting over"
+ ));
+ return (GuardState::default(), StateRead::Corrupt);
+ }
+ Err(e) => {
+ log(&format!(
+ "DMA-BUF crash guard state could not be read ({e}); deciding without \
+ it this launch and leaving the file as it is"
+ ));
+ return (GuardState::default(), StateRead::Unreadable);
+ }
+ };
+ match load_state(text.as_deref()) {
+ Ok(s) => (s, StateRead::Read),
+ Err(why) => {
+ log(&format!(
+ "DMA-BUF crash guard state unreadable ({why}); starting over"
+ ));
+ (GuardState::default(), StateRead::Corrupt)
+ }
+ }
+}
+
+/// Reads the state file; see [`interpret_state_read`].
+pub fn read_state_file(dir: &Path, log: &mut dyn FnMut(&str)) -> (GuardState, StateRead) {
+ interpret_state_read(fs::read_to_string(dir.join(STATE_FILE)), log)
+}
+
+fn save_state_file(dir: &Path, state: &GuardState, log: &mut dyn FnMut(&str)) {
+ if let Err(e) = store_state(dir, STATE_FILE, state) {
+ log(&format!(
+ "could not save the DMA-BUF crash guard state: {e}"
+ ));
+ }
+}
+
+/// What the shell reads from the process and the system for one launch.
+pub struct Host<'a> {
+ pub webkit_var: Option<&'a str>,
+ pub mode: Option<&'a str>,
+ pub wayland_display: Option<&'a str>,
+ pub session_type: Option<&'a str>,
+ /// `/proc/driver/nvidia/version`, present only while the NVIDIA kernel
+ /// driver is loaded; nouveau never creates it.
+ pub nvidia_version: Option<&'a str>,
+ /// `/proc/self/maps`.
+ pub proc_maps: Option<&'a str>,
+ pub boot_id: Option<&'a str>,
+ pub own_pid: u32,
+ /// Unix seconds.
+ pub now: u64,
+}
+
+/// Everything a launch does with the guard's files, before GTK starts:
+/// read and consume the previous launch's marker, read the state, decide,
+/// log and save. The caller applies the decision to the environment.
+/// `dir` is the data dir, `None` when it could not be found.
+pub fn startup(
+ dir: Option<&Path>,
+ host: &Host,
+ pid_alive: &dyn Fn(u32) -> bool,
+ log: &mut dyn FnMut(&str),
+) -> Decision {
+ if let Some(d) = dir {
+ sweep_stale_tmp(d, host.own_pid, pid_alive);
+ }
+ let marker = dir.and_then(|d| fs::read_to_string(d.join(MARKER_FILE)).ok());
+ let previous = classify_previous(marker.as_deref(), host.boot_id, host.own_pid, pid_alive);
+ if let Some(d) = dir {
+ // Consumed here, read once: a launch that never reaches the window
+ // (no display, an early setup error) writes no marker of its own,
+ // and the next launch must not count the same early exit again.
+ // Another instance's live marker is left for it to clear.
+ if marker.is_some() && previous != PreviousLaunch::StillRunning {
+ let _ = fs::remove_file(d.join(MARKER_FILE));
+ }
+ }
+ let (state, read) = match dir {
+ Some(d) => read_state_file(d, log),
+ None => (GuardState::default(), StateRead::Read),
+ };
+ let fingerprint = fingerprint_from(host.proc_maps, host.nvidia_version);
+ let decision = decide(&Inputs {
+ webkit_var: host.webkit_var,
+ mode: host.mode,
+ wayland_display: host.wayland_display,
+ session_type: host.session_type,
+ nvidia_driver: host.nvidia_version.is_some(),
+ state: &state,
+ previous,
+ fingerprint: fingerprint.as_deref(),
+ now: host.now,
+ });
+ if let Some(raw) = &decision.invalid_mode {
+ log(&format!(
+ "STEMDECK_WEBKIT_DMABUF={raw} is not auto, on or off; using auto"
+ ));
+ }
+ if let Some(d) = dir {
+ if fs::remove_file(d.join(LEGACY_FALLBACK_FILE)).is_ok() {
+ log(
+ "dropped a DMA-BUF fallback saved after a single early exit; \
+ the crash guard now needs two in a row",
+ );
+ }
+ for event in &decision.events {
+ log(&event.describe());
+ }
+ if read.write_back(decision.state != state) {
+ save_state_file(d, &decision.state, log);
+ }
+ }
+ log(&format!("webkit dmabuf: {}", decision.reason.describe()));
+ decision
+}
+
+/// The marker for this launch, written just before the window is built.
+pub fn write_launch_marker(
+ dir: &Path,
+ own_pid: u32,
+ boot_id: Option<&str>,
+ renderer_on: bool,
+ now: u64,
+) -> io::Result<()> {
+ let marker = Marker {
+ pid: own_pid,
+ started: now,
+ boot_id: boot_id.map(str::to_string),
+ renderer_on,
+ };
+ write_atomic(dir, MARKER_FILE, &format_marker(&marker))
+}
+
+/// The window loaded and stayed up: clear this launch's marker, then credit
+/// the launch in the guard's state.
+pub fn record_clean_run(dir: &Path, own_pid: u32, renderer_on: bool, log: &mut dyn FnMut(&str)) {
+ clear_marker_if_ours(dir, own_pid);
+ let (state, read) = read_state_file(dir, log);
+ let next = after_clean_run(&state, renderer_on);
+ if !read.write_back(next != state) {
+ return;
+ }
+ if renderer_on && state.early_exits > 0 {
+ log("launch loaded cleanly with the DMA-BUF renderer on; early-exit count reset");
+ }
+ if let Some(fb) = &next.fallback {
+ log(&format!(
+ "launch loaded cleanly under the saved DMA-BUF fallback ({} of {} \
+ before the renderer is tried again)",
+ fb.clean_runs,
+ retry_after(next.failed_retries)
+ ));
+ }
+ save_state_file(dir, &next, log);
+}
+
+#[cfg(target_os = "linux")]
+pub use shell::{apply_at_startup, clear_on_exit, watch_first_load};
+
+#[cfg(target_os = "linux")]
+mod shell {
+ use super::*;
+ use std::env;
+ use std::path::PathBuf;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::OnceLock;
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+ /// How long after the first page load the marker stays. The protocol
+ /// error kills the process on the first frame the webview commits, within
+ /// milliseconds of the page loading, so five seconds is generous. Past it,
+ /// a crash is not the renderer at startup and must not count.
+ const READY_GRACE: Duration = Duration::from_secs(5);
+
+ /// WebKitGTK's own switch. Read and set, never overridden once present.
+ const WEBKIT_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
+ /// StemDeck's override: `auto` (default), `on` or `off`.
+ const MODE_VAR: &str = "STEMDECK_WEBKIT_DMABUF";
+
+ struct Armed {
+ dir: PathBuf,
+ renderer_on: bool,
+ }
+
+ /// Set when this launch armed the guard.
+ static ARMED: OnceLock = OnceLock::new();
+
+ fn log(data_dir: Option<&Path>, msg: &str) {
+ eprintln!("[stemdeck] {msg}");
+ if let Some(d) = data_dir {
+ crate::append_to_setup_log(d, msg);
+ }
+ }
+
+ fn var(name: &str) -> Option {
+ env::var_os(name).map(|v| v.to_string_lossy().into_owned())
+ }
+
+ fn now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|t| t.as_secs())
+ .unwrap_or(0)
+ }
+
+ fn boot_id() -> Option {
+ fs::read_to_string("/proc/sys/kernel/random/boot_id")
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ }
+
+ fn pid_alive(pid: u32) -> bool {
+ Path::new(&format!("/proc/{pid}")).exists()
+ }
+
+ /// Decide and apply, before GTK, WebKit or any other thread exists.
+ pub fn apply_at_startup() {
+ let data_dir = crate::local_data_dir().ok();
+ let dir = data_dir.as_deref();
+ let nvidia_version = fs::read_to_string("/proc/driver/nvidia/version").ok();
+ // The WebKitGTK library is linked in, so it is mapped before main.
+ let maps = fs::read_to_string("/proc/self/maps").ok();
+ let boot = boot_id();
+ let webkit_var = var(WEBKIT_VAR);
+ let mode = var(MODE_VAR);
+ let wayland_display = var("WAYLAND_DISPLAY");
+ let session_type = var("XDG_SESSION_TYPE");
+ let host = Host {
+ webkit_var: webkit_var.as_deref(),
+ mode: mode.as_deref(),
+ wayland_display: wayland_display.as_deref(),
+ session_type: session_type.as_deref(),
+ nvidia_version: nvidia_version.as_deref(),
+ proc_maps: maps.as_deref(),
+ boot_id: boot.as_deref(),
+ own_pid: std::process::id(),
+ now: now(),
+ };
+ let decision = startup(dir, &host, &pid_alive, &mut |m| log(dir, m));
+ if decision.disable_dmabuf {
+ // Edition 2021: set_var is safe to call, but it is only sound
+ // while no other thread reads the environment. This runs first
+ // thing in main, before Tauri, GTK or WebKit start a thread.
+ env::set_var(WEBKIT_VAR, "1");
+ }
+ if let (Some(d), true) = (dir, decision.arm_guard) {
+ let _ = ARMED.set(Armed {
+ dir: d.to_path_buf(),
+ renderer_on: !decision.disable_dmabuf,
+ });
+ }
+ }
+
+ /// Writes the launch marker and clears it once the first page has loaded.
+ /// Called with the main window's builder, after GTK is up, so a launch
+ /// that fails earlier (no display at all) is never read as a crash.
+ pub fn watch_first_load<'a, R: tauri::Runtime, M: tauri::Manager>(
+ builder: tauri::WebviewWindowBuilder<'a, R, M>,
+ ) -> tauri::WebviewWindowBuilder<'a, R, M> {
+ let Some(armed) = ARMED.get() else {
+ return builder;
+ };
+ let own_pid = std::process::id();
+ let boot = boot_id();
+ if let Err(e) = write_launch_marker(
+ &armed.dir,
+ own_pid,
+ boot.as_deref(),
+ armed.renderer_on,
+ now(),
+ ) {
+ // Never a reason not to start: the guard is just off this time.
+ log(Some(&armed.dir), &format!("launch marker not written: {e}"));
+ return builder;
+ }
+ let started = AtomicBool::new(false);
+ builder.on_page_load(move |_window, payload| {
+ if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
+ && !started.swap(true, Ordering::SeqCst)
+ {
+ std::thread::spawn(move || {
+ std::thread::sleep(READY_GRACE);
+ record_clean_run(&armed.dir, own_pid, armed.renderer_on, &mut |m| {
+ log(Some(&armed.dir), m)
+ });
+ });
+ }
+ })
+ }
+
+ /// An orderly quit, however early, is never an early exit. It is not a
+ /// clean run either: it says nothing about whether the renderer holds.
+ pub fn clear_on_exit() {
+ if let Some(armed) = ARMED.get() {
+ clear_marker_if_ours(&armed.dir, std::process::id());
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const NO_STATE: GuardState = GuardState {
+ early_exits: 0,
+ retrying: false,
+ failed_retries: 0,
+ fallback: None,
+ };
+
+ fn inputs() -> Inputs<'static> {
+ Inputs {
+ webkit_var: None,
+ mode: None,
+ wayland_display: None,
+ session_type: None,
+ nvidia_driver: false,
+ state: &NO_STATE,
+ previous: PreviousLaunch::Clean,
+ fingerprint: Some("webkit=libwebkit2gtk-4.1.so.0.13.5"),
+ now: 1_700_000_000,
+ }
+ }
+
+ fn wayland_nvidia() -> Inputs<'static> {
+ Inputs {
+ wayland_display: Some("wayland-1"),
+ session_type: Some("wayland"),
+ nvidia_driver: true,
+ ..inputs()
+ }
+ }
+
+ const CRASH_ON: PreviousLaunch = PreviousLaunch::Crashed { renderer_on: true };
+ const CRASH_OFF: PreviousLaunch = PreviousLaunch::Crashed { renderer_on: false };
+
+ fn saved(clean_runs: u32, failed_retries: u32) -> GuardState {
+ GuardState {
+ failed_retries,
+ fallback: Some(Fallback {
+ since: 1,
+ clean_runs,
+ fingerprint: Some("webkit=libwebkit2gtk-4.1.so.0.13.5".into()),
+ }),
+ ..GuardState::default()
+ }
+ }
+
+ /// One launch's decision, from the state the launch before it left and
+ /// how that launch ended. Chained with `after_clean_run` to play out a
+ /// row of launches the way the shell runs them.
+ fn launch(state: &GuardState, previous: PreviousLaunch) -> Decision {
+ decide(&Inputs {
+ state,
+ previous,
+ ..inputs()
+ })
+ }
+
+ #[test]
+ fn wayland_with_nvidia_turns_the_renderer_off() {
+ let d = decide(&wayland_nvidia());
+ assert!(d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::WaylandNvidia);
+ assert!(d.arm_guard);
+ assert_eq!(d.state, GuardState::default());
+ }
+
+ #[test]
+ fn wayland_without_nvidia_leaves_it_on() {
+ let d = decide(&Inputs {
+ nvidia_driver: false,
+ ..wayland_nvidia()
+ });
+ assert!(!d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::NoRuleMatched);
+ assert!(d.arm_guard);
+ }
+
+ #[test]
+ fn x11_session_with_nvidia_leaves_it_on() {
+ let d = decide(&Inputs {
+ session_type: Some("x11"),
+ nvidia_driver: true,
+ ..inputs()
+ });
+ assert!(!d.disable_dmabuf);
+ assert!(d.arm_guard);
+ }
+
+ #[test]
+ fn xwayland_counts_as_wayland() {
+ // GDK_BACKEND=x11 under Hyprland: WAYLAND_DISPLAY is still set.
+ let d = decide(&Inputs {
+ wayland_display: Some("wayland-1"),
+ session_type: None,
+ nvidia_driver: true,
+ ..inputs()
+ });
+ assert!(d.disable_dmabuf);
+ assert!(is_wayland(None, Some("Wayland")));
+ assert!(!is_wayland(Some(" "), Some("x11")));
+ }
+
+ #[test]
+ fn a_value_the_user_set_wins_over_everything() {
+ let state = saved(0, 0);
+ for webkit in ["1", "0", ""] {
+ let d = decide(&Inputs {
+ webkit_var: Some(webkit),
+ mode: Some("off"),
+ state: &state,
+ previous: CRASH_ON,
+ ..wayland_nvidia()
+ });
+ assert!(!d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::UserSetWebkitVar);
+ assert!(!d.arm_guard);
+ assert_eq!(d.state, state, "nothing saved or forgotten");
+ assert!(d.events.is_empty());
+ }
+ }
+
+ #[test]
+ fn mode_on_keeps_the_renderer_and_forgets_everything_saved() {
+ let state = GuardState {
+ early_exits: 1,
+ retrying: true,
+ ..saved(3, 2)
+ };
+ let d = decide(&Inputs {
+ mode: Some("ON"),
+ state: &state,
+ previous: CRASH_ON,
+ ..wayland_nvidia()
+ });
+ assert!(!d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::ForcedOn);
+ assert!(!d.arm_guard);
+ assert_eq!(d.state, GuardState::default());
+ assert_eq!(d.events, vec![GuardEvent::Forgotten]);
+ let none_saved = decide(&Inputs {
+ mode: Some("on"),
+ ..inputs()
+ });
+ assert!(none_saved.events.is_empty());
+ }
+
+ #[test]
+ fn mode_off_turns_it_off_anywhere_and_saves_nothing() {
+ let d = decide(&Inputs {
+ mode: Some(" off "),
+ previous: CRASH_ON,
+ ..inputs()
+ });
+ assert!(d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::ForcedOff);
+ assert!(!d.arm_guard);
+ assert_eq!(d.state, GuardState::default());
+ }
+
+ #[test]
+ fn mode_auto_and_unset_run_the_rules() {
+ for mode in [None, Some("auto"), Some("")] {
+ let d = decide(&Inputs {
+ mode,
+ ..wayland_nvidia()
+ });
+ assert_eq!(d.reason, Reason::WaylandNvidia);
+ assert_eq!(d.invalid_mode, None);
+ }
+ }
+
+ #[test]
+ fn an_invalid_mode_is_reported_and_treated_as_auto() {
+ let d = decide(&Inputs {
+ mode: Some("yes"),
+ ..wayland_nvidia()
+ });
+ assert_eq!(d.invalid_mode.as_deref(), Some("yes"));
+ assert_eq!(d.reason, Reason::WaylandNvidia);
+ assert_eq!(parse_mode(Some("maybe")), Err("maybe".to_string()));
+ }
+
+ #[test]
+ fn one_unrelated_early_exit_does_not_save_the_fallback() {
+ let d = launch(&GuardState::default(), CRASH_ON);
+ assert!(!d.disable_dmabuf, "the renderer is tried again");
+ assert_eq!(d.reason, Reason::NoRuleMatched);
+ assert!(d.arm_guard);
+ assert_eq!(d.state.early_exits, 1);
+ assert_eq!(d.state.fallback, None);
+ assert_eq!(d.events, vec![GuardEvent::EarlyExitCounted { count: 1 }]);
+ // That launch then loads cleanly: the count is gone.
+ assert_eq!(after_clean_run(&d.state, true), GuardState::default());
+ }
+
+ #[test]
+ fn two_early_exits_in_a_row_save_the_fallback() {
+ let first = launch(&GuardState::default(), CRASH_ON);
+ let second = launch(&first.state, CRASH_ON);
+ assert!(second.disable_dmabuf);
+ assert_eq!(second.reason, Reason::EarlyExitsInARow);
+ assert!(second.arm_guard);
+ let fb = second.state.fallback.clone().expect("fallback saved");
+ assert_eq!(fb.since, 1_700_000_000);
+ assert_eq!(fb.clean_runs, 0);
+ assert_eq!(
+ fb.fingerprint.as_deref(),
+ Some("webkit=libwebkit2gtk-4.1.so.0.13.5")
+ );
+ assert_eq!(second.state.early_exits, 0);
+ assert_eq!(
+ second.events,
+ vec![
+ GuardEvent::EarlyExitCounted { count: 2 },
+ GuardEvent::FallbackSaved {
+ retry_after: RETRY_AFTER_CLEAN_RUNS,
+ after_retry: false,
+ },
+ ]
+ );
+ assert!(!second.events[0].describe().contains("tried again"));
+ // And it holds on the launch after.
+ let third = launch(&second.state, PreviousLaunch::Clean);
+ assert!(third.disable_dmabuf);
+ assert_eq!(third.reason, Reason::SavedFallback);
+ }
+
+ #[test]
+ fn early_exits_with_the_renderer_off_are_not_counted() {
+ let d = launch(&GuardState::default(), CRASH_OFF);
+ assert_eq!(d.state.early_exits, 0);
+ assert_eq!(d.events, vec![GuardEvent::EarlyExitNotCounted]);
+ let one = GuardState {
+ early_exits: 1,
+ ..GuardState::default()
+ };
+ assert_eq!(launch(&one, CRASH_OFF).state.fallback, None);
+ }
+
+ #[test]
+ fn a_clean_run_in_between_breaks_the_row() {
+ let first = launch(&GuardState::default(), CRASH_ON);
+ let cleared = after_clean_run(&first.state, true);
+ let again = launch(&cleared, CRASH_ON);
+ assert_eq!(again.state.fallback, None);
+ assert_eq!(again.state.early_exits, 1);
+ }
+
+ #[test]
+ fn a_clean_run_with_the_renderer_off_says_nothing_about_it() {
+ let one = GuardState {
+ early_exits: 1,
+ ..GuardState::default()
+ };
+ assert_eq!(after_clean_run(&one, false), one);
+ }
+
+ #[test]
+ fn clean_runs_under_the_fallback_bring_the_renderer_back() {
+ let mut state = saved(0, 0);
+ for n in 1..RETRY_AFTER_CLEAN_RUNS {
+ let d = launch(&state, PreviousLaunch::Clean);
+ assert_eq!(d.reason, Reason::SavedFallback, "launch {n}");
+ state = after_clean_run(&d.state, !d.disable_dmabuf);
+ }
+ let held = launch(&state, PreviousLaunch::Clean);
+ assert_eq!(held.reason, Reason::SavedFallback);
+ state = after_clean_run(&held.state, false);
+ assert_eq!(
+ state.fallback.as_ref().map(|f| f.clean_runs),
+ Some(RETRY_AFTER_CLEAN_RUNS)
+ );
+
+ let retry = launch(&state, PreviousLaunch::Clean);
+ assert!(!retry.disable_dmabuf, "back on the fast path");
+ assert_eq!(retry.reason, Reason::NoRuleMatched);
+ assert_eq!(retry.state.fallback, None);
+ assert!(retry.state.retrying);
+ assert_eq!(
+ retry.events,
+ vec![GuardEvent::RetryAfterCleanRuns {
+ clean_runs: RETRY_AFTER_CLEAN_RUNS
+ }]
+ );
+ // The retry loads cleanly: every count against the renderer goes.
+ assert_eq!(after_clean_run(&retry.state, true), GuardState::default());
+ }
+
+ #[test]
+ fn a_failed_retry_puts_the_fallback_back_and_waits_longer() {
+ let retry = launch(&saved(RETRY_AFTER_CLEAN_RUNS, 0), PreviousLaunch::Clean);
+ let after = launch(&retry.state, CRASH_ON);
+ assert!(after.disable_dmabuf);
+ assert_eq!(after.reason, Reason::EarlyExitsInARow);
+ assert_eq!(after.state.failed_retries, 1);
+ assert!(!after.state.retrying);
+ assert!(after.events.contains(&GuardEvent::FallbackSaved {
+ retry_after: RETRY_AFTER_CLEAN_RUNS * 2,
+ after_retry: true,
+ }));
+ assert_eq!(retry_after(0), 10);
+ assert_eq!(retry_after(1), 20);
+ assert_eq!(retry_after(3), 80);
+ assert_eq!(retry_after(u32::MAX), 80, "capped");
+ }
+
+ #[test]
+ fn a_changed_graphics_stack_brings_the_renderer_back() {
+ let state = saved(0, 2);
+ let d = decide(&Inputs {
+ state: &state,
+ fingerprint: Some("webkit=libwebkit2gtk-4.1.so.0.14.0"),
+ ..inputs()
+ });
+ assert!(!d.disable_dmabuf);
+ assert_eq!(d.state.fallback, None);
+ assert!(matches!(
+ d.events.as_slice(),
+ [GuardEvent::RetryAfterChange { .. }]
+ ));
+ // A fingerprint that cannot be read now is not a change.
+ let unknown = decide(&Inputs {
+ state: &state,
+ fingerprint: None,
+ ..inputs()
+ });
+ assert_eq!(unknown.reason, Reason::SavedFallback);
+ }
+
+ #[test]
+ fn the_wayland_rule_still_applies_while_retrying() {
+ let state = saved(RETRY_AFTER_CLEAN_RUNS, 0);
+ let d = decide(&Inputs {
+ state: &state,
+ ..wayland_nvidia()
+ });
+ assert!(d.disable_dmabuf);
+ assert_eq!(d.reason, Reason::WaylandNvidia);
+ }
+
+ #[test]
+ fn a_live_or_stale_previous_launch_changes_nothing() {
+ for previous in [PreviousLaunch::StillRunning, PreviousLaunch::Stale] {
+ let d = launch(&GuardState::default(), previous);
+ assert!(!d.disable_dmabuf);
+ assert_eq!(d.state, GuardState::default());
+ assert!(d.events.is_empty());
+ }
+ }
+
+ #[test]
+ fn every_reason_and_event_has_a_log_line() {
+ for r in [
+ Reason::UserSetWebkitVar,
+ Reason::ForcedOn,
+ Reason::ForcedOff,
+ Reason::SavedFallback,
+ Reason::EarlyExitsInARow,
+ Reason::WaylandNvidia,
+ Reason::NoRuleMatched,
+ ] {
+ assert!(r.describe().is_ascii() && !r.describe().is_empty());
+ }
+ for e in [
+ GuardEvent::EarlyExitCounted { count: 1 },
+ GuardEvent::EarlyExitNotCounted,
+ GuardEvent::EarlyExitCounted { count: 2 },
+ GuardEvent::FallbackSaved {
+ retry_after: 10,
+ after_retry: false,
+ },
+ GuardEvent::FallbackSaved {
+ retry_after: 20,
+ after_retry: true,
+ },
+ GuardEvent::RetryAfterCleanRuns { clean_runs: 10 },
+ GuardEvent::RetryAfterChange {
+ was: "a".into(),
+ now: "b".into(),
+ },
+ GuardEvent::Forgotten,
+ ] {
+ let line = e.describe();
+ assert!(line.is_ascii() && !line.is_empty() && !line.contains('\n'));
+ assert!(!line.contains(" "), "no stray indentation: {line}");
+ }
+ }
+
+ #[test]
+ fn state_round_trips() {
+ for s in [
+ GuardState::default(),
+ GuardState {
+ early_exits: 1,
+ retrying: true,
+ failed_retries: 2,
+ fallback: None,
+ },
+ saved(4, 1),
+ GuardState {
+ fallback: Some(Fallback {
+ since: 9,
+ clean_runs: 0,
+ fingerprint: None,
+ }),
+ ..GuardState::default()
+ },
+ ] {
+ assert_eq!(parse_state(&format_state(&s)), Ok(s));
+ }
+ }
+
+ #[test]
+ fn a_corrupt_state_file_is_reported_not_trusted() {
+ for text in [
+ "garbage",
+ "early_exits=two\n",
+ "retrying=yes\n",
+ "fallback_since=-1\n",
+ "fallback_clean_runs=\n",
+ ] {
+ assert!(load_state(Some(text)).is_err(), "{text:?}");
+ }
+ // No file, an empty one, and keys a later version added are fine.
+ assert_eq!(load_state(None), Ok(GuardState::default()));
+ assert_eq!(load_state(Some("")), Ok(GuardState::default()));
+ assert_eq!(
+ load_state(Some("early_exits=1\nnew_key=x\n")),
+ Ok(GuardState {
+ early_exits: 1,
+ ..GuardState::default()
+ })
+ );
+ // Counts without a saved fallback never invent one.
+ assert_eq!(
+ load_state(Some("fallback_clean_runs=3\n")).map(|s| s.fallback),
+ Ok(None)
+ );
+ }
+
+ #[test]
+ fn a_fingerprint_with_a_newline_cannot_break_the_file() {
+ let s = GuardState {
+ fallback: Some(Fallback {
+ since: 1,
+ clean_runs: 0,
+ fingerprint: Some("a\nearly_exits=9".into()),
+ }),
+ ..GuardState::default()
+ };
+ let back = parse_state(&format_state(&s)).unwrap();
+ assert_eq!(back.early_exits, 0);
+ }
+
+ #[test]
+ fn the_fingerprint_names_webkit_and_the_nvidia_driver() {
+ let maps = "7f00-7f01 r-xp 00000000 08:01 123 /usr/lib/x86_64-linux-gnu/libc.so.6\n\
+ 7f02-7f03 r-xp 00000000 08:01 456 \
+ /usr/lib/x86_64-linux-gnu/libwebkit2gtk-4.1.so.0.13.5\n\
+ 7f04-7f05 rw-p 00000000 00:00 0\n";
+ let nvidia = "NVRM version: NVIDIA UNIX x86_64 Kernel Module 550.54.14 Thu Feb 22\n\
+ GCC version: gcc 13\n";
+ assert_eq!(
+ fingerprint_from(Some(maps), Some(nvidia)).as_deref(),
+ Some(
+ "webkit=libwebkit2gtk-4.1.so.0.13.5; \
+ nvidia=NVRM version: NVIDIA UNIX x86_64 Kernel Module 550.54.14 Thu Feb 22"
+ )
+ );
+ assert_eq!(
+ fingerprint_from(Some(maps), None).as_deref(),
+ Some("webkit=libwebkit2gtk-4.1.so.0.13.5")
+ );
+ assert_eq!(fingerprint_from(Some("no libraries here"), None), None);
+ assert_eq!(fingerprint_from(None, None), None);
+ }
+
+ #[test]
+ fn the_state_file_goes_away_when_there_is_nothing_to_keep() {
+ let tmp = tempfile::tempdir().unwrap();
+ let state = saved(1, 0);
+ store_state(tmp.path(), "guard", &state).unwrap();
+ let text = fs::read_to_string(tmp.path().join("guard")).unwrap();
+ assert_eq!(parse_state(&text), Ok(state));
+ store_state(tmp.path(), "guard", &GuardState::default()).unwrap();
+ assert!(!tmp.path().join("guard").exists());
+ // Removing a file that is already gone is not an error.
+ store_state(tmp.path(), "guard", &GuardState::default()).unwrap();
+ assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 0);
+ }
+
+ fn marker(pid: u32, boot: Option<&str>) -> String {
+ format_marker(&Marker {
+ pid,
+ started: 1_700_000_000,
+ boot_id: boot.map(str::to_string),
+ renderer_on: true,
+ })
+ }
+
+ #[test]
+ fn marker_round_trips() {
+ for renderer_on in [true, false] {
+ let m = Marker {
+ pid: 42,
+ started: 7,
+ boot_id: Some("abc".into()),
+ renderer_on,
+ };
+ assert_eq!(parse_marker(&format_marker(&m)), Some(m));
+ }
+ assert_eq!(parse_marker("pid=0\nstarted=1\n"), None);
+ assert_eq!(parse_marker("garbage"), None);
+ // A marker from before #993 has no renderer line: counted as on.
+ assert!(parse_marker("pid=5\nstarted=1\n").unwrap().renderer_on);
+ }
+
+ #[test]
+ fn marker_with_a_dead_pid_is_a_crash() {
+ let text = marker(4242, Some("boot-a"));
+ let got = classify_previous(Some(&text), Some("boot-a"), 1, |_| false);
+ assert_eq!(got, CRASH_ON);
+ // No boot id on either side: still decided by the pid.
+ let text = marker(4242, None);
+ assert_eq!(classify_previous(Some(&text), None, 1, |_| false), CRASH_ON);
+ let off = format_marker(&Marker {
+ pid: 4242,
+ started: 1,
+ boot_id: None,
+ renderer_on: false,
+ });
+ assert_eq!(classify_previous(Some(&off), None, 1, |_| false), CRASH_OFF);
+ }
+
+ #[test]
+ fn marker_with_a_live_pid_is_another_instance() {
+ let text = marker(4242, Some("boot-a"));
+ let got = classify_previous(Some(&text), Some("boot-a"), 1, |p| p == 4242);
+ assert_eq!(got, PreviousLaunch::StillRunning);
+ }
+
+ #[test]
+ fn no_marker_means_a_clean_previous_launch() {
+ assert_eq!(
+ classify_previous(None, Some("boot-a"), 1, |_| false),
+ PreviousLaunch::Clean
+ );
+ }
+
+ #[test]
+ fn stale_markers_are_ignored() {
+ let other_boot = marker(4242, Some("boot-a"));
+ assert_eq!(
+ classify_previous(Some(&other_boot), Some("boot-b"), 1, |_| false),
+ PreviousLaunch::Stale
+ );
+ let own_pid = marker(7, None);
+ assert_eq!(
+ classify_previous(Some(&own_pid), None, 7, |_| false),
+ PreviousLaunch::Stale
+ );
+ assert_eq!(
+ classify_previous(Some("half a fi"), None, 1, |_| false),
+ PreviousLaunch::Stale
+ );
+ }
+
+ #[test]
+ fn a_clean_quit_leaves_no_marker_behind() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_atomic(tmp.path(), MARKER_FILE, &marker(99, None)).unwrap();
+ // Another instance's marker is left alone.
+ assert!(!clear_marker_if_ours(tmp.path(), 98));
+ assert!(tmp.path().join(MARKER_FILE).exists());
+ assert!(clear_marker_if_ours(tmp.path(), 99));
+ let left = fs::read_to_string(tmp.path().join(MARKER_FILE)).ok();
+ assert_eq!(
+ classify_previous(left.as_deref(), None, 1, |_| false),
+ PreviousLaunch::Clean
+ );
+ // No temp file is left next to it.
+ assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 0);
+ }
+
+ #[test]
+ fn an_unwritable_data_dir_fails_quietly() {
+ let tmp = tempfile::tempdir().unwrap();
+ // A regular file where the directory should be: create_dir_all fails
+ // on every platform, without depending on permissions or root.
+ let blocker = tmp.path().join("not-a-dir");
+ fs::write(&blocker, "").unwrap();
+ let dir = blocker.join("stemdeck");
+ assert!(write_atomic(&dir, MARKER_FILE, &marker(5, None)).is_err());
+ assert!(store_state(&dir, "guard", &saved(0, 0)).is_err());
+ assert!(!clear_marker_if_ours(&dir, 5));
+ assert_eq!(
+ classify_previous(None, None, 5, |_| false),
+ PreviousLaunch::Clean
+ );
+ }
+
+ // Through the file layer: `startup`, `write_launch_marker`,
+ // `record_clean_run` and `clear_marker_if_ours`, in a temp data dir, in
+ // the order the shell calls them across launches.
+
+ fn host(own_pid: u32) -> Host<'static> {
+ Host {
+ webkit_var: None,
+ mode: None,
+ wayland_display: None,
+ session_type: None,
+ nvidia_version: None,
+ proc_maps: None,
+ boot_id: Some("boot-a"),
+ own_pid,
+ now: 1_700_000_000,
+ }
+ }
+
+ fn dead(_: u32) -> bool {
+ false
+ }
+
+ /// One launch's startup, with its log lines.
+ fn start(dir: &Path, own_pid: u32, alive: &dyn Fn(u32) -> bool) -> (Decision, Vec) {
+ let mut lines = Vec::new();
+ let d = startup(Some(dir), &host(own_pid), alive, &mut |m| {
+ lines.push(m.to_string())
+ });
+ (d, lines)
+ }
+
+ /// What the shell does once the window is about to be built.
+ fn reach_window(dir: &Path, own_pid: u32, d: &Decision) {
+ assert!(d.arm_guard);
+ write_launch_marker(dir, own_pid, Some("boot-a"), !d.disable_dmabuf, 1).unwrap();
+ }
+
+ fn state_on_disk(dir: &Path) -> GuardState {
+ load_state(fs::read_to_string(dir.join(STATE_FILE)).ok().as_deref()).unwrap()
+ }
+
+ #[test]
+ fn one_early_exit_counts_once_when_the_next_launch_never_reaches_the_window() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+
+ // Launch 2 counts the early exit, then fails before its window (no
+ // display, an early setup error) and writes no marker of its own.
+ let (second, _) = start(dir, 100, &dead);
+ assert_eq!(second.state.early_exits, 1);
+ assert!(!dir.join(MARKER_FILE).exists(), "the marker is consumed");
+
+ // Launch 3 must not read the same early exit a second time.
+ let (third, _) = start(dir, 101, &dead);
+ assert_eq!(third.state.early_exits, 1);
+ assert_eq!(third.state.fallback, None);
+ assert!(!third.disable_dmabuf);
+ assert_eq!(state_on_disk(dir).early_exits, 1);
+ }
+
+ #[test]
+ fn two_real_early_exits_save_the_fallback_and_clean_runs_count_under_it() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+
+ let (second, _) = start(dir, 100, &dead);
+ reach_window(dir, 100, &second);
+ // Launch 2 dies early too, with the renderer on.
+ let (third, log) = start(dir, 101, &dead);
+ assert!(third.disable_dmabuf);
+ assert_eq!(third.reason, Reason::EarlyExitsInARow);
+ assert!(log.iter().any(|l| l.contains("is now off")), "{log:?}");
+ assert!(state_on_disk(dir).fallback.is_some());
+
+ // Launch 3 loads and stays up under the fallback.
+ reach_window(dir, 101, &third);
+ let mut lines = Vec::new();
+ record_clean_run(dir, 101, false, &mut |m| lines.push(m.to_string()));
+ assert!(!dir.join(MARKER_FILE).exists());
+ let fb = state_on_disk(dir).fallback.unwrap();
+ assert_eq!(fb.clean_runs, 1);
+ assert!(lines.iter().any(|l| l.contains("1 of 10")), "{lines:?}");
+ }
+
+ #[test]
+ fn a_clean_run_clears_the_marker_and_the_count() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+ let (d, _) = start(dir, 100, &dead);
+ assert_eq!(state_on_disk(dir).early_exits, 1);
+ reach_window(dir, 100, &d);
+
+ let mut lines = Vec::new();
+ record_clean_run(dir, 100, true, &mut |m| lines.push(m.to_string()));
+ assert!(!dir.join(MARKER_FILE).exists());
+ assert!(!dir.join(STATE_FILE).exists(), "nothing left to keep");
+ assert!(lines.iter().any(|l| l.contains("count reset")), "{lines:?}");
+
+ // A clean run with nothing to change writes and logs nothing.
+ let mut quiet = Vec::new();
+ record_clean_run(dir, 100, true, &mut |m| quiet.push(m.to_string()));
+ assert!(quiet.is_empty());
+ assert_eq!(fs::read_dir(dir).unwrap().count(), 0);
+ }
+
+ #[test]
+ fn a_clean_run_leaves_another_instances_marker() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 200, Some("boot-a"), true, 1).unwrap();
+ record_clean_run(dir, 100, true, &mut |_| {});
+ assert_eq!(
+ parse_marker(&fs::read_to_string(dir.join(MARKER_FILE)).unwrap()).map(|m| m.pid),
+ Some(200)
+ );
+ }
+
+ #[test]
+ fn an_orderly_early_quit_is_not_counted_and_resets_nothing() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+ let (d, _) = start(dir, 100, &dead);
+ reach_window(dir, 100, &d);
+ // Quit before the 5 seconds were up: the marker goes, the count stays.
+ assert!(clear_marker_if_ours(dir, 100));
+ let (next, _) = start(dir, 101, &dead);
+ assert_eq!(next.state.early_exits, 1);
+ assert_eq!(next.state.fallback, None);
+ }
+
+ #[test]
+ fn a_live_instances_marker_is_left_alone() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+ let (d, _) = start(dir, 100, &|p| p == 4242);
+ assert_eq!(d.state.early_exits, 0);
+ assert!(dir.join(MARKER_FILE).exists());
+ }
+
+ #[test]
+ fn a_state_file_that_cannot_be_read_is_logged_and_left_alone() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ // A directory where the file should be: reading it fails with an I/O
+ // error, not NotFound and not bad content, on every platform and
+ // without depending on permissions or root.
+ fs::create_dir(dir.join(STATE_FILE)).unwrap();
+ fs::write(dir.join(STATE_FILE).join("kept"), "").unwrap();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+
+ let (d, log) = start(dir, 100, &dead);
+ // The launch goes on, deciding without the state.
+ assert!(d.arm_guard);
+ assert_eq!(d.state.early_exits, 1);
+ assert!(
+ log.iter().any(|l| l.contains("could not be read")),
+ "{log:?}"
+ );
+ // Nothing is written over it or deleted.
+ assert!(!log.iter().any(|l| l.contains("could not save")), "{log:?}");
+ assert!(dir.join(STATE_FILE).join("kept").exists());
+
+ // The clean-run write stands back too.
+ reach_window(dir, 100, &d);
+ let mut lines = Vec::new();
+ record_clean_run(dir, 100, true, &mut |m| lines.push(m.to_string()));
+ assert!(
+ !lines.iter().any(|l| l.contains("could not save")),
+ "{lines:?}"
+ );
+ assert!(dir.join(STATE_FILE).join("kept").exists());
+ }
+
+ #[test]
+ fn a_read_error_never_writes_back_and_bad_content_always_does() {
+ let mut quiet = |_: &str| {};
+ for kind in [
+ io::ErrorKind::PermissionDenied,
+ io::ErrorKind::Interrupted,
+ io::ErrorKind::Other,
+ ] {
+ let (s, read) = interpret_state_read(Err(io::Error::from(kind)), &mut quiet);
+ assert_eq!(read, StateRead::Unreadable, "{kind:?}");
+ assert_eq!(s, GuardState::default());
+ assert!(!read.write_back(true) && !read.write_back(false));
+ }
+ let (_, missing) =
+ interpret_state_read(Err(io::Error::from(io::ErrorKind::NotFound)), &mut quiet);
+ assert_eq!(missing, StateRead::Read);
+ assert!(missing.write_back(true) && !missing.write_back(false));
+ let (_, not_text) =
+ interpret_state_read(Err(io::Error::from(io::ErrorKind::InvalidData)), &mut quiet);
+ assert_eq!(not_text, StateRead::Corrupt);
+ let (_, bad) = interpret_state_read(Ok("early_exits=x".into()), &mut quiet);
+ assert_eq!(bad, StateRead::Corrupt);
+ assert!(bad.write_back(false));
+ }
+
+ #[test]
+ fn a_saved_fallback_is_not_rewritten_by_a_launch_that_changes_nothing() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ store_state(dir, STATE_FILE, &saved(3, 1)).unwrap();
+ let before = fs::read(dir.join(STATE_FILE)).unwrap();
+ // The previous launch closed early under the fallback: not counted.
+ write_launch_marker(dir, 4242, Some("boot-a"), false, 1).unwrap();
+ let (d, _) = start(dir, 100, &dead);
+ assert_eq!(d.reason, Reason::SavedFallback);
+ assert_eq!(fs::read(dir.join(STATE_FILE)).unwrap(), before);
+ }
+
+ #[test]
+ fn a_crashed_marker_is_consumed_whatever_the_override() {
+ for (webkit_var, mode) in [
+ (Some("1"), None),
+ (Some(""), Some("on")),
+ (None, Some("off")),
+ (None, Some("on")),
+ ] {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ write_launch_marker(dir, 4242, Some("boot-a"), true, 1).unwrap();
+ let d = startup(
+ Some(dir),
+ &Host {
+ webkit_var,
+ mode,
+ ..host(100)
+ },
+ &dead,
+ &mut |_| {},
+ );
+ assert!(!d.arm_guard, "{webkit_var:?} {mode:?}");
+ assert!(
+ !dir.join(MARKER_FILE).exists(),
+ "{webkit_var:?} {mode:?}: marker left behind"
+ );
+ }
+ }
+
+ #[test]
+ fn a_stale_marker_is_consumed() {
+ let other_boot = format_marker(&Marker {
+ pid: 4242,
+ started: 1,
+ boot_id: Some("boot-b".into()),
+ renderer_on: true,
+ });
+ let own_pid = format_marker(&Marker {
+ pid: 100,
+ started: 1,
+ boot_id: None,
+ renderer_on: true,
+ });
+ for text in [other_boot.as_str(), own_pid.as_str(), "half a fi"] {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ fs::write(dir.join(MARKER_FILE), text).unwrap();
+ let (d, _) = start(dir, 100, &dead);
+ assert_eq!(d.state.early_exits, 0, "{text:?}");
+ assert!(
+ !dir.join(MARKER_FILE).exists(),
+ "{text:?}: marker left behind"
+ );
+ }
+ }
+
+ #[test]
+ fn a_corrupt_state_file_is_logged_and_replaced() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ fs::write(dir.join(STATE_FILE), "early_exits=lots\n").unwrap();
+ let (d, log) = start(dir, 100, &dead);
+ assert_eq!(d.state, GuardState::default());
+ assert!(log.iter().any(|l| l.contains("unreadable")), "{log:?}");
+ assert!(!dir.join(STATE_FILE).exists(), "replaced by no state");
+ }
+
+ #[test]
+ fn stale_temp_files_are_swept_at_startup() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ for name in [
+ ".launch-pending.4242.tmp",
+ ".webkit-dmabuf-guard.4243.tmp",
+ ".launch-pending.4244.tmp",
+ ".webkit-dmabuf-guard.100.tmp",
+ ".other.4242.tmp",
+ "notes.txt",
+ ] {
+ fs::write(dir.join(name), "").unwrap();
+ }
+ start(dir, 100, &|p| p == 4244);
+ let mut left: Vec = fs::read_dir(dir)
+ .unwrap()
+ .map(|e| e.unwrap().file_name().into_string().unwrap())
+ .collect();
+ left.sort();
+ assert_eq!(
+ left,
+ vec![
+ ".launch-pending.4244.tmp",
+ ".other.4242.tmp",
+ ".webkit-dmabuf-guard.100.tmp",
+ "notes.txt",
+ ]
+ );
+ assert_eq!(stale_tmp_pid(".launch-pending.7.tmp"), Some(7));
+ assert_eq!(stale_tmp_pid(".launch-pending.0.tmp"), None);
+ assert_eq!(stale_tmp_pid(".launch-pending.x.tmp"), None);
+ for odd in ["+7", "007", "-7", " 7", "7 ", "", "4294967296"] {
+ assert_eq!(
+ stale_tmp_pid(&format!(".launch-pending.{odd}.tmp")),
+ None,
+ "{odd:?}"
+ );
+ }
+ assert_eq!(
+ stale_tmp_pid(".webkit-dmabuf-guard.4294967295.tmp"),
+ Some(u32::MAX)
+ );
+ assert_eq!(stale_tmp_pid("launch-pending.7.tmp"), None);
+ }
+
+ #[test]
+ fn a_legacy_fallback_file_is_dropped_and_logged() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path();
+ fs::write(dir.join(LEGACY_FALLBACK_FILE), "since=1\n").unwrap();
+ let (d, log) = start(dir, 100, &dead);
+ assert!(!d.disable_dmabuf);
+ assert!(!dir.join(LEGACY_FALLBACK_FILE).exists());
+ assert!(log.iter().any(|l| l.contains("two in a row")), "{log:?}");
+ }
+
+ #[test]
+ fn no_data_dir_still_decides_and_logs() {
+ let mut lines = Vec::new();
+ let d = startup(None, &host(100), &dead, &mut |m| lines.push(m.to_string()));
+ assert!(d.arm_guard);
+ assert_eq!(lines.len(), 1);
+ assert!(lines[0].starts_with("webkit dmabuf: "));
+ }
+}
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index 735bdaa2..79f62f5b 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -1,6 +1,10 @@
+mod backendjob;
mod certs;
+#[cfg(any(target_os = "linux", test))]
+mod dmabuf;
mod dragout;
mod dropin;
+mod mediaperm;
#[cfg(any(not(target_os = "macos"), test))]
mod torchswap;
@@ -9,7 +13,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use socket2::{Domain, Protocol, Socket, Type};
use std::{
- collections::HashMap,
+ collections::{BTreeSet, HashMap},
env, fs,
io::{BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpStream},
@@ -17,7 +21,7 @@ use std::{
process::{Child, Command, Output, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
- Arc, Mutex,
+ Arc, Mutex, OnceLock,
},
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
@@ -60,26 +64,31 @@ const APP_EXE_NAME: &str = "StemDeck.exe";
#[cfg(not(windows))]
const APP_EXE_NAME: &str = "StemDeck";
// Windows FFmpeg comes from BtbN's GitHub build (served via GitHub's CDN, far
-// faster worldwide than the old gyan.dev single mirror -- #248). Unlike gyan.dev,
-// which published a per-file `{url}.sha256` companion, BtbN publishes ONE combined
-// `checksums.sha256` listing every asset as ` ` lines; we fetch it
-// and pick the line for our archive's basename. The `latest` tag is rolling (the
-// `n8.1-latest` asset is rebuilt in place), so the checksum is fetched fresh each
-// run and verified -- the same trust model as the old gyan flow. A compile-time pin
-// is not possible without self-hosting the archive.
+// faster worldwide than the old gyan.dev single mirror -- #248), pinned to one
+// dated month-end autobuild and verified by SHA256 before it is extracted, like
+// the macOS pins below. The rolling `latest` tag is avoided on purpose: its
+// n8.1.3 gcc 16.2 rebuilds abort at random with "stack smashing detected"
+// (3 to 5 runs in 100 of a plain decode), while this gcc 15.2 build crashed 0
+// times in 2400 runs. The hash matches GitHub's asset digest and BtbN's checksums.sha256 for
+// the release, so it is pinned here rather than fetched from the same host.
+//
+// BtbN keeps month-end autobuilds for about two years (daily ones for about
+// two weeks), so this release disappears around 2028-08. Bump the tag, the
+// asset and the hash together before then, and the e2e.yml FFMPEG_RELEASE pin
+// with them. STEMDECK_FFMPEG_URL still overrides the source, unverified.
const DEFAULT_WINDOWS_FFMPEG_URL: &str =
- "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-win64-gpl-8.1.zip";
-#[cfg(windows)]
-const DEFAULT_WINDOWS_FFMPEG_CHECKSUMS_URL: &str =
- "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/checksums.sha256";
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-31-13-27/ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip";
+#[cfg(any(windows, test))]
+const DEFAULT_WINDOWS_FFMPEG_SHA256: &str =
+ "273abb45f3f9f76c303e35ff39f5bb6c23c163ae65f6244a32b7d4a7f6cf0616";
// macOS FFmpeg is pinned to a specific evermeet build and verified by SHA256
// before it is extracted or executed (#172). evermeet publishes no .sha256
-// companion (only a GPG signature and a size), so unlike the Windows BtbN
-// path we cannot fetch the hash at runtime -- instead we pin the hash of a
+// companion (only a GPG signature and a size), so we pin the hash of a
// specific versioned zip, captured at build time from evermeet's TLS endpoint
// (the download size matched evermeet's signed release info). Bump the version
// and BOTH hashes together when updating FFmpeg. The rolling getrelease/latest
// URL is intentionally avoided so the pinned hash stays valid.
+#[cfg(any(target_os = "macos", test))]
const DEFAULT_MACOS_FFMPEG_URL: &str = "https://evermeet.cx/ffmpeg/ffmpeg-8.1.1.zip";
#[cfg(target_os = "macos")]
const DEFAULT_MACOS_FFPROBE_URL: &str = "https://evermeet.cx/ffmpeg/ffprobe-8.1.1.zip";
@@ -93,15 +102,15 @@ const DEFAULT_MACOS_FFPROBE_SHA256: &str =
// source via GitHub Actions and served from GitHub Releases -- GitHub's
// global CDN behind it, the same class of fix that already solved this for
// Windows (#248, moved off gyan.dev's single mirror). evermeet.cx above is
-// now the fallback only: a single host with no CDN, reported unreachable
-// from multiple regions (#388). Binaries are raw (not zip-wrapped) and
-// published per-architecture. All four hashes were independently verified
+// now the fallback only, and on Intel Macs only (#858): a single host with
+// no CDN, reported unreachable from multiple regions (#388). Binaries are
+// raw (not zip-wrapped) and published per-architecture. All four hashes were independently verified
// (downloaded, sha256'd, and cross-checked against the release notes' own
// published MD5s and each binary's Mach-O magic bytes) before pinning here.
// Bump the release tag and all four hashes together when updating.
-#[cfg(target_os = "macos")]
+#[cfg(any(target_os = "macos", test))]
const SHAKA_FFMPEG_RELEASE: &str = "n8.1.2-1";
-#[cfg(target_os = "macos")]
+#[cfg(any(target_os = "macos", test))]
const SHAKA_FFMPEG_BASE_URL: &str =
"https://github.com/shaka-project/static-ffmpeg-binaries/releases/download";
#[cfg(target_os = "macos")]
@@ -145,6 +154,44 @@ const DEFAULT_LINUX_FFMPEG_SHA256: &str =
struct BackendHandles {
child: Child,
url: String,
+ /// The port in `url`, which a restart asks for again so the page keeps
+ /// its origin.
+ port: u16,
+ /// Windows: the kill-on-close job the backend runs in. Dropping it (the
+ /// backend stopped, or StemDeck gone) ends every process the backend
+ /// started, ffmpeg renders included (#790). Always None elsewhere.
+ _job: Option,
+}
+
+struct SetupChild {
+ label: String,
+ #[cfg(unix)]
+ pid: u32,
+ /// The job it runs in (run_setup_child): ending the job ends whatever the
+ /// child started as well.
+ #[cfg(windows)]
+ job: Option>,
+}
+
+impl SetupChild {
+ fn stop(&self) -> Result<(), String> {
+ // SAFETY: the pid was stored right after spawn and is cleared once the
+ // child has been waited on; SIGTERM is best-effort.
+ #[cfg(unix)]
+ unsafe {
+ libc::kill(self.pid as libc::pid_t, libc::SIGTERM)
+ };
+ #[cfg(windows)]
+ match &self.job {
+ Some(job) => job
+ .terminate()
+ .map_err(|e| format!("could not stop the {}: {e}", self.label))?,
+ // spawn_in_job could not make a job and said so; there is no
+ // handle left here to end the child with.
+ None => return Err(format!("could not stop the {}: it has no job", self.label)),
+ }
+ Ok(())
+ }
}
#[derive(Default)]
@@ -152,9 +199,23 @@ struct BackendStateInner {
handles: Option,
/// True while start_backend is executing; prevents concurrent starts (#145).
starting: bool,
- /// PID of an in-progress setup-time subprocess (pip install, or the model
- /// warmup download, #275); killed by stop_backend on window close (#140).
- setup_child_pid: Option,
+ /// The setup-time subprocess in progress (pip install, or the model warmup
+ /// download, #275), stopped by stop_backend on window close (#140) and by
+ /// the wizard's Skip (#405).
+ setup_child: Option,
+ /// Set by stop_backend as the window closes. The setup commands run off
+ /// the main thread, so one can go on to its next child in the moment
+ /// before StemDeck exits; run_setup_child refuses it, as nothing would be
+ /// left to stop it.
+ closing: bool,
+ /// The setup step running (SetupStep): one at a time, so a second run of
+ /// the wizard, after a reload say, cannot work on python/ or the runtime
+ /// beside the first, or read the first one's busy slot as "no GPU".
+ setup_step: Option<&'static str>,
+ /// While apply_app_update renames the new build into place (UpdateSwap).
+ /// The close waits for it: cut between two renames, the install would
+ /// have no backend/ or no executable.
+ swapping: bool,
/// Save destinations the user has picked but not yet downloaded to (#338).
///
/// The export is two commands so the UI can tell "choosing a folder" apart
@@ -167,6 +228,12 @@ struct BackendStateInner {
/// secret. Every live token maps to a path the user chose in a native
/// dialog, so guessing one only ever yields another approved destination.
next_save_token: u64,
+ /// The files completed exports were written to, by the token of their
+ /// save, for Streamer Mode's "Show in folder": a caller can reveal only
+ /// the file its own save wrote, never another caller's later one. Set
+ /// only by download_to_path after the whole file is on disk; JS never
+ /// hands Rust a path to reveal.
+ saved_files: HashMap,
}
/// Cap on unconsumed destinations. A pick whose download never runs (the user
@@ -178,6 +245,15 @@ struct BackendState {
inner: Mutex,
}
+/// The URL of the backend this process is running, if any. Read by the media
+/// permission handlers each time a page asks, because a restart can move the
+/// backend to another port.
+fn current_backend_url(app: &tauri::AppHandle) -> Option {
+ let state = app.try_state::()?;
+ let inner = state.inner.lock().ok()?;
+ inner.handles.as_ref().map(|h| h.url.clone())
+}
+
impl Default for BackendState {
fn default() -> Self {
BackendState {
@@ -328,6 +404,10 @@ struct AssetStatus {
struct GpuSetup {
gpu_detected: bool,
gpu_name: Option,
+ /// "cuda" (NVIDIA) or "rocm" (AMD): which torch build serves the GPU. Both
+ /// run as torch's "cuda" device, so torch_device cannot tell them apart.
+ backend: Option,
+ /// The driver's CUDA version, or the ROCm line of the AMD build.
cuda_version: Option,
torch_device: String,
cuda_verified: bool,
@@ -357,6 +437,12 @@ fn main() {
return;
}
+ // Before Tauri, GTK and WebKit start, and before any other thread
+ // exists: the WebKit renderer switch is read once at WebKit init (#780).
+ #[cfg(target_os = "linux")]
+ dmabuf::apply_at_startup();
+ apply_portable_env();
+
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_store::Builder::default().build())
@@ -404,14 +490,28 @@ fn main() {
.cloned()
.ok_or("tauri.conf.json describes no main window")?;
main_window.drag_drop_enabled = dropin::NATIVE_FILE_DROP;
- let window =
- tauri::WebviewWindowBuilder::from_config(app.handle(), &main_window)?.build()?;
+ let mut builder = tauri::WebviewWindowBuilder::from_config(app.handle(), &main_window)?;
+ if let (Ok(root), Ok(data_dir)) = (app_root(), local_data_dir()) {
+ if let Some(dir) = portable_webview_dir(&root, &data_dir) {
+ builder = builder.data_directory(dir);
+ }
+ }
+ // Arms the launch crash guard and clears it once the page loads.
+ #[cfg(target_os = "linux")]
+ let builder = dmabuf::watch_first_load(builder);
+ let window = builder.build()?;
// Before anything else: a minimum the screen cannot satisfy leaves
// a window the user cannot resize down to fit, and that is not
// recoverable from inside the app.
fit_min_size_to_screen(&window);
+ // Microphone and camera for the studio page, which records takes
+ // and performance videos. Granted to the running backend's origin
+ // only; see mediaperm.rs for what each webview needs.
+ let handle = app.handle().clone();
+ mediaperm::install(&window, move || current_backend_url(&handle));
+
let data_dir = match local_data_dir() {
Ok(d) => d,
Err(e) => {
@@ -434,7 +534,9 @@ fn main() {
// never be retried. Verified: after a real self-update both
// backend.old and StemDeck.exe.old were still on disk. Three path
// checks per launch is nothing; leaking ~30 MB forever is not.
- sweep_update_leftovers();
+ if let Ok(root) = app_root() {
+ sweep_update_leftovers(&root);
+ }
let version_file = data_dir.join("last_version.txt");
let migration_flag = data_dir.join("store_migration_done");
@@ -512,15 +614,16 @@ fn main() {
download_runtime_pack,
verify_runtime_pack,
extract_runtime_pack,
- installed_runtime_id,
check_app_update,
download_app_update,
apply_app_update,
ensure_external_assets,
ensure_torch_device,
+ repair_torchvision,
warmup_models,
+ skip_model_warmup,
start_backend,
- local_ip,
+ restart_backend,
build_target,
open_url,
native_file_drop,
@@ -529,25 +632,39 @@ fn main() {
save_audio_file,
pick_export_destination,
download_to_path,
+ reveal_saved,
pick_stems_folder,
pick_exports_folder,
current_exports_dir,
start_audio_drag,
store_get,
store_set,
+ store_set_many,
reset_user_data,
mark_store_migration_done,
])
.build(tauri::generate_context!())
.expect("failed to build StemDeck desktop app")
.run(|app_handle, event| {
+ if matches!(event, tauri::RunEvent::Exit) {
+ // Every orderly exit (the updater's relaunch included), not
+ // only the window close, so it is never read as an early
+ // exit (#993).
+ #[cfg(target_os = "linux")]
+ dmabuf::clear_on_exit();
+ }
if let tauri::RunEvent::WindowEvent {
- event: tauri::WindowEvent::CloseRequested { .. },
+ event: tauri::WindowEvent::CloseRequested { api, .. },
..
} = event
{
let state = app_handle.state::();
- stop_backend(&state);
+ if !stop_backend(&state) {
+ api.prevent_close();
+ return;
+ }
+ #[cfg(target_os = "linux")]
+ dmabuf::clear_on_exit();
app_handle.exit(0);
}
});
@@ -567,9 +684,32 @@ fn main() {
/// ~/Documents/StemDeck/jobs, since the backend's own ensure_runtime_dirs
/// (app/core/config.py) already mkdirs whichever JOBS_DIR actually wins that
/// precedence -- this path only needs to exist when it is the one in use.
-fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Result {
- let documents = app.path().document_dir().map_err(|e| e.to_string())?;
- Ok(documents.join("StemDeck"))
+///
+/// None for a portable package (see installed_documents_dir), or when the OS
+/// has no Documents folder.
+fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Option {
+ let documents = || app.path().document_dir().ok().map(|d| d.join("StemDeck"));
+ match app_root() {
+ Ok(root) => installed_documents_dir(&root, documents),
+ Err(_) => documents(),
+ }
+}
+
+/// `documents` for an installed app, None for a portable package.
+///
+/// ~/Documents/StemDeck is where an installed StemDeck keeps its library. A
+/// portable package keeps its own in data/jobs, or in the folder the user picks
+/// in Settings, so it never looks there: a portable run that adopted that
+/// folder opened another install's songs, and its reset or sweep deleted them.
+fn installed_documents_dir(
+ root: &Path,
+ documents: impl FnOnce() -> Option,
+) -> Option {
+ if is_portable_package(root) {
+ None
+ } else {
+ documents()
+ }
}
/// The stems/jobs folder as it exists right now: the backend's own
@@ -615,7 +755,7 @@ fn documents_store_path(app: &tauri::AppHandle) -> Result {
.map_err(|e| format!("failed to create {}: {e}", jobs_dir.display()))?;
let new_path = jobs_dir.join("user-data.json");
if !new_path.is_file() {
- if let Ok(old_path) = documents_stemdeck_dir(app).map(|d| d.join("user-data.json")) {
+ if let Some(old_path) = documents_stemdeck_dir(app).map(|d| d.join("user-data.json")) {
if old_path.is_file() && old_path != new_path {
let _ = fs::copy(&old_path, &new_path);
}
@@ -624,14 +764,6 @@ fn documents_store_path(app: &tauri::AppHandle) -> Result {
Ok(new_path)
}
-/// True if `path` exists and contains at least one entry. Used to tell an
-/// already-in-use default folder apart from one nothing has ever written to.
-fn directory_has_entries(path: &Path) -> bool {
- fs::read_dir(path)
- .map(|mut entries| entries.next().is_some())
- .unwrap_or(false)
-}
-
/// The DEFAULT stems folder. Does NOT create it -- see documents_stemdeck_dir
/// for why.
///
@@ -641,45 +773,18 @@ fn directory_has_entries(path: &Path) -> bool {
/// is the one that has to move the library when it changes, including
/// creating whichever path wins (app/core/config.py's ensure_runtime_dirs).
///
-/// Two candidates, resolved in this order:
-///
-/// 1. ~/Documents/StemDeck/jobs, if it already has anything in it. Every
-/// install before this default existed used this path, so an existing
-/// user's real library lives there without any explicit `jobs_dir` in
-/// settings.json to record it -- it was simply "the default." Checking
-/// disk content directly (rather than writing a one-time migration flag
-/// into settings.json, which only the backend otherwise writes) keeps this
-/// self-contained: nothing to persist, no other-process race, and it stays
-/// correct on every future launch for as long as that folder holds data.
-/// 2. Otherwise, for the Windows portable package, local_data_dir()/jobs --
-/// i.e. next to data/cache and data/models inside the package itself,
-/// rather than leaving a footprint in Documents. Non-portable installs
-/// (installer builds, macOS, Linux) keep candidate 1 either way: the
-/// original Documents rationale (visible in Finder/Explorer, eligible for
-/// OneDrive/iCloud backup, survives reinstalls) still applies to them.
+/// ~/Documents/StemDeck/jobs for installer builds, macOS and Linux: visible in
+/// Finder/Explorer, eligible for OneDrive/iCloud backup, and it survives
+/// reinstalls. local_data_dir()/jobs for the Windows portable package, next to
+/// data/cache and data/models inside the package itself, and wherever the OS
+/// has no Documents folder.
fn documents_dir_for_jobs(app: &tauri::AppHandle) -> PathBuf {
- let legacy_default = match documents_stemdeck_dir(app) {
- Ok(dir) => dir.join("jobs"),
- Err(_) => {
- return local_data_dir()
- .map(|d| d.join("jobs"))
- .unwrap_or_else(|_| PathBuf::from("jobs"));
- }
- };
-
- if directory_has_entries(&legacy_default) {
- return legacy_default;
- }
-
- if let Ok(root) = app_root() {
- if is_portable_package(&root) {
- if let Ok(data_dir) = local_data_dir() {
- return data_dir.join("jobs");
- }
- }
+ match documents_stemdeck_dir(app) {
+ Some(dir) => dir.join("jobs"),
+ None => local_data_dir()
+ .map(|d| d.join("jobs"))
+ .unwrap_or_else(|_| PathBuf::from("jobs")),
}
-
- legacy_default
}
/// Native folder picker for the stems location. Returns None when the user
@@ -771,6 +876,52 @@ fn store_set(app: tauri::AppHandle, key: String, value: serde_json::Value) -> Re
store.save().map_err(|e| e.to_string())
}
+/// What store_set_many needs of the store: set a key in memory, and write the
+/// whole file. A trait so its unit test can count the writes.
+trait KeyValueStore {
+ fn set_value(&self, key: String, value: serde_json::Value);
+ fn save_file(&self) -> Result<(), String>;
+}
+
+impl KeyValueStore for tauri_plugin_store::Store {
+ fn set_value(&self, key: String, value: serde_json::Value) {
+ self.set(key, value);
+ }
+ fn save_file(&self) -> Result<(), String> {
+ self.save().map_err(|e| e.to_string())
+ }
+}
+
+/// Every key in the store, then one write of the file; nothing written when
+/// there are no keys.
+fn set_all_then_save(
+ store: &impl KeyValueStore,
+ entries: serde_json::Map,
+) -> Result<(), String> {
+ if entries.is_empty() {
+ return Ok(());
+ }
+ for (key, value) in entries {
+ store.set_value(key, value);
+ }
+ store.save_file()
+}
+
+/// Set several keys in the persistent user-data store and flush to disk once
+/// (#996). Each save writes the whole store file, which holds every song's
+/// mixes: on a 9 MB store one save took 47 to 83 ms, so a version delete that
+/// cleared its eight records with store_set took 386 to 626 ms. Synchronous
+/// like store_set, so both run in the order the page sent them.
+#[tauri::command]
+fn store_set_many(
+ app: tauri::AppHandle,
+ entries: serde_json::Map,
+) -> Result<(), String> {
+ let path = documents_store_path(&app)?;
+ let store = app.store(path).map_err(|e| e.to_string())?;
+ set_all_then_save(store.as_ref(), entries)
+}
+
/// Clear the persistent user-data store entirely (Settings -> General ->
/// "Reset app data"). Complements the backend's own job-data wipe (POST
/// /api/reset) -- together they fully clear a user's local StemDeck state,
@@ -823,7 +974,12 @@ fn clear_webkit_data() {
/// Returns current runtime state: Python path, FFmpeg path, and persisted torch device.
#[tauri::command]
-fn probe_runtime() -> Result {
+async fn probe_runtime() -> Result {
+ // It runs the interpreter and the FFmpeg pair.
+ off_main_thread(probe_runtime_blocking).await
+}
+
+fn probe_runtime_blocking() -> Result {
log_setup_step("probe-runtime");
let root = app_root()?;
let data_dir = local_data_dir()?;
@@ -831,22 +987,20 @@ fn probe_runtime() -> Result {
if let Some(path) = python.as_deref() {
patch_pyvenv_cfg(path);
}
- let ffmpeg = resolve_existing_ffmpeg(&data_dir);
- // Whether the pair runs, not whether a file is there.
- //
- // is_file() on ffmpeg alone is what let #637 survive its own fix. A
- // wrong-architecture pair is a pair that exists, so this reported ready,
- // setup.js short-circuited straight to the studio, and ensure_ffmpeg --
- // which does verify, fall through and recover -- was never reached. The
- // failure then surfaced inside a job as "Could not read file duration:
- // [Errno 86]", which is where the reporter met it.
- //
- // Reached once per launch, and only when a binary is actually present, so
- // the cost is two -version calls on a path that otherwise skips setup
- // entirely.
- let ffmpeg_ready = ffmpeg
- .as_deref()
- .is_some_and(|path| verify_ffmpeg_pair(path).is_ok());
+ // A STEMDECK_FFMPEG that verifies is ready as named, STEMDECK_FFPROBE
+ // included. One that fails, with a recorded pair to fall back to, is
+ // ready when that pair still runs: the backend gets it instead
+ // (decide_backend_ffmpeg, #1042). Otherwise, as before.
+ let (ffmpeg, ffmpeg_ready) = match backend_ffmpeg_decision(&data_dir).choice {
+ BackendFfmpeg::Env(ffmpeg, _) => (Some(ffmpeg), true),
+ BackendFfmpeg::RecordedOverEnv(ffmpeg, ffprobe) => {
+ let ready = check_pair_cached(&ffmpeg, &ffprobe).is_ok();
+ (Some(ffmpeg), ready)
+ }
+ BackendFfmpeg::Recorded(..) | BackendFfmpeg::Inherit => {
+ existing_ffmpeg_readiness(&data_dir)
+ }
+ };
let torch_device = read_config_str(&data_dir, "torchDevice");
let torch_device_reason = effective_device_reason(
read_config_str(&data_dir, "torchDeviceReason"),
@@ -866,6 +1020,30 @@ fn probe_runtime() -> Result {
})
}
+// The FFmpeg resolve_existing_ffmpeg finds, and whether setup can skip it.
+fn existing_ffmpeg_readiness(data_dir: &Path) -> (Option, bool) {
+ let ffmpeg = resolve_existing_ffmpeg(data_dir);
+ // Whether the pair runs, not whether a file is there.
+ //
+ // is_file() on ffmpeg alone is what let #637 survive its own fix. A
+ // wrong-architecture pair is a pair that exists, so this reported ready,
+ // setup.js short-circuited straight to the studio, and ensure_ffmpeg --
+ // which does verify, fall through and recover -- was never reached. The
+ // failure then surfaced inside a job as "Could not read file duration:
+ // [Errno 86]", which is where the reporter met it.
+ //
+ // Reached once per launch, and only when a binary is actually present, so
+ // the cost is two -version calls on a path that otherwise skips setup
+ // entirely.
+ //
+ // A known-bad managed build (gcc 16, see is_known_bad_ffmpeg_build) also
+ // reads as not ready, so setup reaches ensure_ffmpeg, which replaces it.
+ let ffmpeg_ready = ffmpeg.as_deref().is_some_and(|path| {
+ verify_ffmpeg_pair(path).is_ok() && !managed_ffmpeg_needs_replacing(data_dir, path)
+ });
+ (ffmpeg, ffmpeg_ready)
+}
+
/// Read a single string field from data/config.json, returning None on any error.
fn read_config_str(data_dir: &std::path::Path, key: &str) -> Option {
let text = fs::read_to_string(data_dir.join("config.json")).ok()?;
@@ -925,7 +1103,11 @@ async fn download_runtime_pack(app_handle: tauri::AppHandle) -> Result Result {
+async fn verify_runtime_pack() -> Result {
+ off_main_thread(verify_runtime_pack_blocking).await
+}
+
+fn verify_runtime_pack_blocking() -> Result {
let root = app_root()?;
let data_dir = local_data_dir()?;
let manifest = load_runtime_manifest(&root)?;
@@ -936,7 +1118,14 @@ fn verify_runtime_pack() -> Result {
/// Extracts the verified runtime pack archive and atomically swaps it into place.
#[tauri::command]
-fn extract_runtime_pack() -> Result {
+async fn extract_runtime_pack(app: tauri::AppHandle) -> Result {
+ // A close mid-extract is recovered from: runtime.tmp is cleared on the
+ // next try, and ensure_workspace puts runtime.old back.
+ off_main_thread(move || extract_runtime_pack_blocking(&app.state())).await
+}
+
+fn extract_runtime_pack_blocking(state: &BackendState) -> Result {
+ let _step = SetupStep::take(state, "runtime install")?;
log_setup_step("extract-runtime-pack");
ensure_workspace()?;
let root = app_root()?;
@@ -993,14 +1182,13 @@ fn extract_runtime_pack() -> Result {
fs::rename(&extracted, &runtime).map_err(|e| format!("failed to install runtime: {e}"))?;
// Cleanup is non-fatal; log warnings rather than silently discarding errors.
- if let Err(e) = fs::remove_dir_all(&tmp) {
- if let Ok(d) = local_data_dir() {
- append_to_setup_log(&d, &format!("cleanup warning: {}: {e}", tmp.display()));
- }
- }
- if let Err(e) = fs::remove_dir_all(&old) {
- if let Ok(d) = local_data_dir() {
- append_to_setup_log(&d, &format!("cleanup warning: {}: {e}", old.display()));
+ // A folder that is not there is not one: runtime.old never exists on a
+ // first install (#858).
+ for path in [&tmp, &old] {
+ if let Some(warning) = cleanup_warning(path, fs::remove_dir_all(path)) {
+ if let Ok(d) = local_data_dir() {
+ append_to_setup_log(&d, &warning);
+ }
}
}
@@ -1026,10 +1214,15 @@ fn extract_runtime_pack() -> Result {
/// Delete the `.old` siblings and staging dir an in-app update leaves at the
/// app root (#421). Best-effort and idempotent: whatever is still locked by the
/// outgoing process this launch is simply picked up on the next one.
-fn sweep_update_leftovers() {
- let Ok(root) = app_root() else { return };
+///
+/// backend.old is kept while there is no backend/app: then it is the only
+/// copy of the app, left by a swap that stopped between its renames.
+fn sweep_update_leftovers(root: &Path) {
for name in ["backend.old", "python.old", "_update_app.tmp"] {
let stale = root.join(name);
+ if name == "backend.old" && !root.join("backend").join("app").is_dir() {
+ continue;
+ }
if stale.is_dir() {
let _ = fs::remove_dir_all(&stale);
}
@@ -1049,7 +1242,7 @@ fn sweep_update_leftovers() {
/// replace python/ (see `AppUpdatePlan`). `None` is treated as "cannot verify",
/// which sends the user to the full-package download rather than risking an app
/// layer whose imports the installed runtime may not satisfy.
-#[tauri::command]
+#[cfg(any(windows, target_os = "linux"))]
fn installed_runtime_id() -> Option {
let root = app_root().ok()?;
let text = fs::read_to_string(root.join("python").join("runtime-version.json")).ok()?;
@@ -1138,9 +1331,12 @@ async fn check_app_update(query: AppUpdateQuery) -> Result Result {
const MAX_BYTES: usize = 64 * 1024;
+ validate_release_url(url)?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(60))
@@ -1222,6 +1418,7 @@ fn app_sha_path(downloads: &Path) -> PathBuf {
downloads.join(format!("{UPDATE_APP_ARCHIVE}.sha256"))
}
+#[cfg(any(windows, target_os = "linux"))]
fn verify_update_sha256(path: &Path, expected: &str, label: &str) -> Result<(), String> {
let actual = sha256_file(path)?;
if !actual.eq_ignore_ascii_case(expected.trim()) {
@@ -1328,8 +1525,8 @@ fn not_writable_message(root: &Path) -> String {
/// while a handle inside it is open, so starting the swap before the process is
/// gone fails with a permission error, or worse, part-way through. Linux would
/// tolerate it, but a backend still serving requests from a directory being
-/// swapped out is not something to rely on either.
-#[cfg(any(windows, target_os = "linux"))]
+/// swapped out is not something to rely on either. restart_backend uses it on
+/// every platform, so the new backend never races the old one for the port.
fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(), String> {
let handles = match state.inner.lock() {
Ok(mut guard) => guard.handles.take(),
@@ -1360,9 +1557,7 @@ fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(),
Ok(Some(_)) => return Ok(()),
Ok(None) => {
if Instant::now() >= deadline {
- return Err(
- "the audio backend did not shut down in time; update cancelled".to_string(),
- );
+ return Err("the audio backend did not shut down in time".to_string());
}
thread::sleep(Duration::from_millis(100));
}
@@ -1375,17 +1570,23 @@ fn stop_backend_and_wait(state: &BackendState, timeout: Duration) -> Result<(),
///
/// Even once the backend process is gone, a virus scanner or the search
/// indexer can hold a transient handle inside a directory that was just
-/// written or is about to move. These clear in well under a second; without a
-/// retry an unlucky scan turns into a failed update mid-swap.
+/// written or is about to move. Most clear in under a second; the budget is
+/// about 5 s so a slower scan of a freshly extracted backend/ does not turn
+/// into a failed update mid-swap. A missing path is not transient, and the
+/// window's close waits for the swap, so that fails at once.
#[cfg(any(windows, target_os = "linux"))]
fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> {
- const ATTEMPTS: u32 = 10;
+ const ATTEMPTS: u32 = 34;
let mut last_err = None;
for attempt in 0..ATTEMPTS {
match fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(e) => {
+ let missing = e.kind() == std::io::ErrorKind::NotFound;
last_err = Some(e);
+ if missing {
+ break;
+ }
if attempt + 1 < ATTEMPTS {
thread::sleep(Duration::from_millis(150));
}
@@ -1400,6 +1601,76 @@ fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> {
))
}
+/// Run the update's renames in order. When one fails, the ones already done
+/// are undone, newest first, so a failed swap leaves the previous install in
+/// place instead of an install with no backend/ beside a backend.old.
+#[cfg(any(windows, target_os = "linux"))]
+fn rename_all_or_restore(moves: &[(&Path, &Path, &str)]) -> Result<(), String> {
+ for (done, (from, to, what)) in moves.iter().enumerate() {
+ if let Err(e) = rename_with_retry(from, to, what) {
+ for (from, to, what) in moves[..done].iter().rev() {
+ rename_with_retry(to, from, &format!("roll back \"{what}\""))
+ .map_err(|undo| format!("{e}; {undo}"))?;
+ }
+ return Err(e);
+ }
+ }
+ Ok(())
+}
+
+/// Holds `starting` for the whole of an app update, so no backend is started
+/// or restarted from backend/ while the update renames it, and the update does
+/// not begin while one is. Released on every way out of apply_app_update.
+#[cfg(any(windows, target_os = "linux"))]
+struct UpdateClaim<'a>(&'a BackendState);
+
+#[cfg(any(windows, target_os = "linux"))]
+impl<'a> UpdateClaim<'a> {
+ fn take(state: &'a BackendState) -> Result {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if inner.starting {
+ return Err("StemDeck is restarting; try the update again in a moment".to_string());
+ }
+ inner.starting = true;
+ Ok(UpdateClaim(state))
+ }
+}
+
+#[cfg(any(windows, target_os = "linux"))]
+impl Drop for UpdateClaim<'_> {
+ fn drop(&mut self) {
+ let mut inner = self.0.inner.lock().unwrap_or_else(|e| e.into_inner());
+ inner.starting = false;
+ }
+}
+
+/// Held while the update's renames run (`swapping`). A close that came first
+/// cancels the swap, and one that comes during it is held off until it ends:
+/// the renames retry for seconds on a scanner's handle, and stopped between
+/// two of them the install has no backend/ or no executable.
+#[cfg(any(windows, target_os = "linux"))]
+struct UpdateSwap<'a>(&'a BackendState);
+
+#[cfg(any(windows, target_os = "linux"))]
+impl<'a> UpdateSwap<'a> {
+ fn take(state: &'a BackendState) -> Result {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if inner.closing {
+ return Err("StemDeck is closing; update cancelled".to_string());
+ }
+ inner.swapping = true;
+ Ok(Self(state))
+ }
+}
+
+#[cfg(any(windows, target_os = "linux"))]
+impl Drop for UpdateSwap<'_> {
+ fn drop(&mut self) {
+ let mut inner = self.0.inner.lock().unwrap_or_else(|e| e.into_inner());
+ inner.swapping = false;
+ }
+}
+
/// Applies a previously downloaded+verified app update in place, then
/// relaunches. This is the one piece of the updater with no existing analog in
/// the runtime-pack machinery above: it replaces the *running* exe, not an idle
@@ -1412,9 +1683,14 @@ fn rename_with_retry(from: &Path, to: &Path, what: &str) -> Result<(), String> {
/// an NVIDIA install keeps its CUDA torch and portable/GPU detection and user
/// data all survive untouched.
#[tauri::command]
-fn apply_app_update(
- state: tauri::State,
- app_handle: tauri::AppHandle,
+async fn apply_app_update(app_handle: tauri::AppHandle) -> Result<(), String> {
+ // The checksum, the extract and the backend stop take seconds each.
+ off_main_thread(move || apply_app_update_blocking(&app_handle.state(), &app_handle)).await
+}
+
+fn apply_app_update_blocking(
+ state: &BackendState,
+ app_handle: &tauri::AppHandle,
) -> Result<(), String> {
#[cfg(not(any(windows, target_os = "linux")))]
{
@@ -1423,6 +1699,7 @@ fn apply_app_update(
}
#[cfg(any(windows, target_os = "linux"))]
{
+ let _claim = UpdateClaim::take(state)?;
let root = app_root()?;
let data_dir = local_data_dir()?;
let downloads = data_dir.join("downloads");
@@ -1476,7 +1753,8 @@ fn apply_app_update(
//
// The backend must be gone first: it runs from backend/, and Windows
// will not rename a directory with live handles inside it.
- stop_backend_and_wait(&state, Duration::from_secs(15))?;
+ stop_backend_and_wait(state, Duration::from_secs(15))
+ .map_err(|e| format!("{e}; update cancelled"))?;
let backend_dir = root.join("backend");
let backend_old = root.join("backend.old");
@@ -1490,18 +1768,20 @@ fn apply_app_update(
let _ = fs::remove_file(&exe_old);
}
+ let staged_backend = staging.join("backend");
+ let mut moves = Vec::new();
if backend_dir.exists() {
- rename_with_retry(
- &backend_dir,
- &backend_old,
+ moves.push((
+ backend_dir.as_path(),
+ backend_old.as_path(),
"move the existing backend aside",
- )?;
+ ));
}
- rename_with_retry(
- &staging.join("backend"),
- &backend_dir,
+ moves.push((
+ staged_backend.as_path(),
+ backend_dir.as_path(),
"install the updated backend",
- )?;
+ ));
// The exe goes last. Windows allows renaming a running process's own
// on-disk image -- the OS holds the file open by handle, not by path --
@@ -1516,8 +1796,20 @@ fn apply_app_update(
// process to self-heal it on next launch. Closing it fully needs a
// separate bootstrap launcher that is never itself replaced; flagging
// it rather than treating it as solved.
- rename_with_retry(&exe_path, &exe_old, "move the running app aside")?;
- rename_with_retry(&new_exe, &exe_path, "install the updated app")?;
+ moves.push((
+ exe_path.as_path(),
+ exe_old.as_path(),
+ "move the running app aside",
+ ));
+ moves.push((
+ new_exe.as_path(),
+ exe_path.as_path(),
+ "install the updated app",
+ ));
+ // Held to the end, so a close cannot fall between the renames and
+ // the relaunch either.
+ let _swap = UpdateSwap::take(state)?;
+ rename_all_or_restore(&moves)?;
let _ = fs::remove_dir_all(&staging);
let _ = fs::remove_file(&app_archive);
@@ -1535,49 +1827,11 @@ fn apply_app_update(
}
}
-/// The per-user directory holding the settings copy that survives reinstalling.
-///
-/// A portable package keeps its data in `/data` (#399), which means
-/// settings.json lives *inside the install*. Upgrading by extracting the new
-/// zip to a fresh folder therefore lost every setting the user had changed:
-/// stems location, port, compute device, quality, language. `ensure_workspace`
-/// already restores from here, but nothing wrote it after #399 moved the data
-/// directory — the backend mirrors to it now, via STEMDECK_SETTINGS_MIRROR.
-///
-/// Deliberately the OS-standard data dir, i.e. exactly what `local_data_dir`
-/// returns for a NON-portable install, so the two layouts share one location
-/// and an install that switches between them keeps its settings either way.
-fn shared_settings_dir() -> Option {
- #[cfg(windows)]
- {
- env::var("LOCALAPPDATA")
- .ok()
- .map(|base| PathBuf::from(base).join("StemDeck"))
- }
- #[cfg(target_os = "macos")]
- {
- env::var("HOME").ok().map(|home| {
- PathBuf::from(home)
- .join("Library")
- .join("Application Support")
- .join("StemDeck")
- })
- }
- #[cfg(all(unix, not(target_os = "macos")))]
- {
- if let Ok(xdg) = env::var("XDG_DATA_HOME") {
- return Some(PathBuf::from(xdg).join("stemdeck"));
- }
- env::var("HOME").ok().map(|home| {
- PathBuf::from(home)
- .join(".local")
- .join("share")
- .join("stemdeck")
- })
- }
-}
-
/// Creates required data directories and runs any pending data migrations.
+///
+/// A portable package starts from its own data folder only. It never takes the
+/// settings an installed StemDeck keeps in the OS data dir: they name that
+/// install's library, and a portable run must not open, reset or sweep it.
#[tauri::command]
fn ensure_workspace() -> Result<(), String> {
log_setup_step("ensure-workspace");
@@ -1593,17 +1847,14 @@ fn ensure_workspace() -> Result<(), String> {
if !runtime_path.exists() && old_path.is_dir() {
let _ = fs::rename(&old_path, &runtime_path);
}
- }
-
- #[cfg(windows)]
- if is_portable_package(&root) {
- // Portable data moved from %LocalAppData% to /data in #399. A
- // freshly extracted package has an empty data directory, so carry the
- // user's prior choices forward before the backend reads settings.json.
- if let Some(shared) = shared_settings_dir() {
- migrate_persisted_files(&shared, &data, &["settings.json"])?;
+ // The same for an FFmpeg swap (swap_in_staged_dir).
+ let ffmpeg_path = data.join("ffmpeg");
+ let ffmpeg_old = data.join("ffmpeg.old");
+ if !ffmpeg_path.exists() && ffmpeg_old.is_dir() {
+ let _ = fs::rename(&ffmpeg_old, &ffmpeg_path);
}
}
+
migrate_legacy_data(&root, &data)?;
fs::create_dir_all(&data).map_err(|e| format!("failed to create data dir: {e}"))?;
for dir in ["cache", "downloads", "ffmpeg", "jobs", "logs", "models"] {
@@ -1623,12 +1874,19 @@ fn ensure_workspace() -> Result<(), String> {
/// Downloads FFmpeg/ffprobe if absent and writes their paths to config.json.
#[tauri::command]
-fn ensure_external_assets() -> Result {
+async fn ensure_external_assets(app: tauri::AppHandle) -> Result {
+ // It downloads FFmpeg. A close mid-swap is recovered from by
+ // ensure_workspace, which puts ffmpeg.old back.
+ off_main_thread(move || ensure_external_assets_blocking(&app.state())).await
+}
+
+fn ensure_external_assets_blocking(state: &BackendState) -> Result {
+ let _step = SetupStep::take(state, "FFmpeg download")?;
log_setup_step("ensure-external-assets");
ensure_workspace()?;
let data_dir = local_data_dir()?;
- let ffmpeg = ensure_ffmpeg(&data_dir)?;
- write_setup_config(&data_dir, &ffmpeg)?;
+ let (ffmpeg, source) = ensure_ffmpeg(&data_dir)?;
+ write_setup_config(&data_dir, &ffmpeg, &source, machine_arch())?;
Ok(AssetStatus {
ffmpeg_ready: true,
ffmpeg_path: Some(ffmpeg.display().to_string()),
@@ -1636,15 +1894,89 @@ fn ensure_external_assets() -> Result {
})
}
-#[derive(Serialize)]
+#[derive(Serialize, Default, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
struct ModelWarmupStatus {
demucs_ready: bool,
beat_this_ready: bool,
sections_ready: bool,
vocal_split_ready: bool,
+ whisper_ready: bool,
+ /// The models that failed because their download host could not be
+ /// reached or stopped answering (WARMUP_OFFLINE).
+ offline: Vec,
+ /// Set when the disk could not hold the models, so none was downloaded.
+ no_space: Option,
+}
+
+#[derive(Serialize, Debug, PartialEq)]
+struct NoSpace {
+ needed: u64,
+ free: u64,
+}
+
+impl ModelWarmupStatus {
+ /// The status `app/pipeline/warmup.py` reported, from its stdout.
+ fn from_stdout(stdout: &str) -> Self {
+ let mut status = Self::default();
+ for line in stdout.lines() {
+ match line.split(' ').collect::>().as_slice() {
+ ["WARMUP_OK", "demucs"] => status.demucs_ready = true,
+ ["WARMUP_OK", "beat_this"] => status.beat_this_ready = true,
+ ["WARMUP_OK", "sections"] => status.sections_ready = true,
+ ["WARMUP_OK", "vocal_split"] => status.vocal_split_ready = true,
+ ["WARMUP_OK", "whisper"] => status.whisper_ready = true,
+ ["WARMUP_OFFLINE", model] => status.offline.push(model.to_string()),
+ ["WARMUP_NO_SPACE", needed, free] => {
+ if let (Ok(needed), Ok(free)) = (needed.parse(), free.parse()) {
+ status.no_space = Some(NoSpace { needed, free });
+ }
+ }
+ _ => {}
+ }
+ }
+ status
+ }
+}
+
+/// One `WARMUP_START ` line, sent to the setup screen as
+/// it arrives so it can say which model it is on (#405).
+#[derive(Clone, Serialize, Debug, PartialEq)]
+struct WarmupProgress {
+ model: String,
+ number: u32,
+ count: u32,
+}
+
+fn warmup_progress(line: &str) -> Option {
+ let mut parts = line.strip_prefix("WARMUP_START ")?.split(' ');
+ let model = parts.next()?.to_string();
+ let number = parts.next()?.parse().ok()?;
+ let count = parts.next()?.parse().ok()?;
+ Some(WarmupProgress {
+ model,
+ number,
+ count,
+ })
}
+const MODEL_WARMUP_LABEL: &str = "model warmup";
+
+/// How long the model warmup may say nothing on either stream before it is
+/// taken for hung (#405). It is not the download bound: the downloads in it
+/// give up by themselves and say so, and tqdm writes to stderr while bytes
+/// arrive. This catches a warmup stuck past those bounds. The longest silence
+/// any of them allows is audio-separator's 300 s read timeout: the real
+/// warmup behind a proxy that never answers was quiet on both streams for
+/// 300 s there and at most 77 s anywhere else. A minute over that, so a dead
+/// connection still gets its own message, naming the model.
+const WARMUP_STALL_TIMEOUT: Duration = Duration::from_secs(6 * 60);
+
+/// Backstop for a warmup that keeps talking and never finishes. The largest
+/// model is 1.6 GB: about 35 minutes at 0.77 MB/s, the slowest connection a
+/// report has measured (#502).
+const WARMUP_HARD_CAP: Duration = Duration::from_secs(2 * 60 * 60);
+
/// Eagerly downloads/caches the ML models StemDeck uses (Demucs, beat-this,
/// automatic song sections, and the on-demand lead/backing vocal-split karaoke model, #275) via
/// `app/pipeline/warmup.py`, so a user's first real job doesn't pay for any
@@ -1653,7 +1985,27 @@ struct ModelWarmupStatus {
/// still proceeds, and that one feature falls back to its existing
/// lazy-download-on-first-use behavior, same as before this step existed.
#[tauri::command]
-fn warmup_models(state: tauri::State) -> Result {
+async fn warmup_models(app: tauri::AppHandle) -> Result {
+ off_main_thread(move || run_model_warmup(&app.state(), &app)).await
+}
+
+/// Runs a command's work on a blocking thread. A command that is not async
+/// runs on the main thread, so for as long as it ran the window could not
+/// paint or close and no other command reached the shell (#405): the model
+/// warmup's Skip, or the close during a CUDA torch install.
+async fn off_main_thread(
+ work: impl FnOnce() -> Result + Send + 'static,
+) -> Result {
+ tauri::async_runtime::spawn_blocking(work)
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+fn run_model_warmup(
+ state: &BackendState,
+ app: &tauri::AppHandle,
+) -> Result {
+ let _step = SetupStep::take(state, MODEL_WARMUP_LABEL)?;
log_setup_step("model-warmup");
let root = app_root()?;
let data_dir = local_data_dir()?;
@@ -1679,36 +2031,32 @@ fn warmup_models(state: tauri::State) -> Result status.demucs_ready = true,
- "WARMUP_OK beat_this" => status.beat_this_ready = true,
- "WARMUP_OK sections" => status.sections_ready = true,
- "WARMUP_OK vocal_split" => status.vocal_split_ready = true,
- _ if line.starts_with("WARMUP_FAILED") => {
- append_to_setup_log(&data_dir, &format!("model warmup: {line}"));
- }
- _ => {}
+ if ["WARMUP_FAILED", "WARMUP_NO_SPACE", "WARMUP_NOTE"]
+ .iter()
+ .any(|kind| line.starts_with(kind))
+ {
+ append_to_setup_log(&data_dir, &format!("model warmup: {line}"));
}
}
if !output.status.success() {
@@ -1721,192 +2069,391 @@ fn warmup_models(state: tauri::State) -> Result,
-) -> Result {
- // Always bind all interfaces; whether other devices are actually served is
- // controlled live by the backend's network gate (Settings → "Make StemDeck
- // available on your network"), which defaults off and always allows
- // loopback. The WebView itself connects via 127.0.0.1 regardless.
- let bind_host = "0.0.0.0";
- // Gate concurrent calls: return immediately if already running or starting (#145).
- {
- let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
- if let Some(ref h) = inner.handles {
- return Ok(BackendStarted { url: h.url.clone() });
- }
- if inner.starting {
- return Err("Backend startup already in progress".to_string());
- }
- inner.starting = true;
+/// Runs a setup child (pip, the model warmup) through `wait`, recorded in
+/// `setup_child` meanwhile so closing the window or Skip can stop it. On
+/// Windows the child runs in a job, which stopping ends and which StemDeck's
+/// exit closes, either way ending it and whatever it started.
+fn run_setup_child(
+ command: &mut Command,
+ state: &BackendState,
+ label: &str,
+ wait: impl FnOnce(Child) -> Result,
+) -> Result {
+ // One at a time: there is one slot to stop a child through, and two
+ // downloads into the same cache would only race each other. The check and
+ // the spawn share the lock so two callers cannot both pass it.
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if inner.closing {
+ return Err(format!("{label} not started: StemDeck is closing"));
+ }
+ if let Some(running) = &inner.setup_child {
+ return Err(format!(
+ "{label} not started: the {} is still running",
+ running.label
+ ));
+ }
+ // Held to the end of this function: dropping the last handle to the job
+ // ends the child.
+ let (child, job) =
+ backendjob::spawn_in_job(command).map_err(|e| format!("failed to start {label}: {e}"))?;
+ let _job = job.map(Arc::new);
+ inner.setup_child = Some(SetupChild {
+ label: label.to_string(),
+ #[cfg(unix)]
+ pid: child.id(),
+ #[cfg(windows)]
+ job: _job.clone(),
+ });
+ drop(inner);
+ let output = wait(child);
+ if let Ok(mut inner) = state.inner.lock() {
+ inner.setup_child = None;
+ }
+ output
+}
+
+/// The claim on `setup_step` a setup command holds while it works, released
+/// on drop. Off the main thread nothing else orders two of them.
+struct SetupStep<'a>(&'a BackendState);
+
+impl<'a> SetupStep<'a> {
+ fn take(state: &'a BackendState, step: &'static str) -> Result {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if let Some(running) = inner.setup_step {
+ return Err(format!(
+ "{step} not started: the {running} is still running"
+ ));
+ }
+ inner.setup_step = Some(step);
+ Ok(Self(state))
+ }
+}
+
+impl Drop for SetupStep<'_> {
+ fn drop(&mut self) {
+ let mut inner = self.0.inner.lock().unwrap_or_else(|e| e.into_inner());
+ inner.setup_step = None;
+ }
+}
+
+/// Err once the window has closed. A probe stopped by the close reads as a
+/// failed one, so the GPU step asks this before it acts on a result: moving
+/// or deleting torch while StemDeck exits would leave it half moved.
+fn still_open(state: &BackendState) -> Result<(), String> {
+ let inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if inner.closing {
+ return Err("stopped: StemDeck is closing".to_string());
+ }
+ Ok(())
+}
+
+/// One of the GPU step's probes (nvidia-smi, a torch import check), run as a
+/// setup child so that closing the window stops it as it stops pip: on
+/// Windows through its job, which also ends what it started; elsewhere with
+/// SIGTERM. A check wedged in the GPU driver otherwise ran on to its timeout
+/// after StemDeck had gone (#502).
+fn setup_probe_output(
+ mut command: Command,
+ state: &BackendState,
+ timeout: Duration,
+ label: &str,
+) -> Result {
+ run_setup_child(&mut command, state, label, |child| {
+ child_output_with_timeout(child, timeout, label)
+ })
+}
+
+/// The wizard's Skip on the model step (#405). Stops the model warmup and
+/// nothing else, so a stray call can never cut a pip install short. Each
+/// model then downloads the first time a feature needs it, as it would have
+/// if this step did not exist.
+#[tauri::command]
+fn skip_model_warmup(state: tauri::State) -> Result<(), String> {
+ if stop_setup_child(&state, MODEL_WARMUP_LABEL)? {
+ if let Ok(data_dir) = local_data_dir() {
+ append_to_setup_log(&data_dir, "model warmup: skipped by the user");
+ }
+ }
+ Ok(())
+}
+
+/// Stops the running setup child if it is the one named `label`, saying
+/// whether it did. Nothing to stop is not an error: it finished first.
+fn stop_setup_child(state: &BackendState, label: &str) -> Result {
+ let inner = state.inner.lock().map_err(|e| e.to_string())?;
+ match &inner.setup_child {
+ Some(child) if child.label == label => child.stop().map(|()| true),
+ _ => Ok(false),
+ }
+}
+
+/// Spawns the Python/uvicorn backend, waits for it to become healthy, and returns its URL.
+#[tauri::command]
+async fn start_backend(app_handle: tauri::AppHandle) -> Result {
+ // The health wait alone can take up to 90 seconds.
+ off_main_thread(move || start_backend_blocking(&app_handle, &app_handle.state())).await
+}
+
+fn start_backend_blocking(
+ app_handle: &tauri::AppHandle,
+ state: &BackendState,
+) -> Result {
+ // Gate concurrent calls: return immediately if already running or starting (#145).
+ {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if let Some(ref h) = inner.handles {
+ return Ok(BackendStarted { url: h.url.clone() });
+ }
+ if inner.starting {
+ return Err("Backend startup already in progress".to_string());
+ }
+ inner.starting = true;
}
// Spawn and wait for health outside the lock; update state atomically on completion.
- let spawn_result = (|| {
- let root = app_root()?;
- let backend_dir = backend_dir(&root)?;
- let data_dir = local_data_dir()?;
- let python = python_path(&root).filter(|p| p.is_file()).ok_or_else(|| {
- "Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string()
- })?;
- patch_pyvenv_cfg(&python);
- let (port, port_guard) = reserve_port(bind_host, configured_port())?;
- // The LAN half of the pair. The webview talks plain http to the
- // loopback listener above, because a self-signed certificate would
- // raise an interstitial a Tauri window has no way to click through;
- // phones get this one, because without TLS their origin is insecure
- // and the browser withholds the AudioWorklet transpose runs on.
- // Best-effort throughout: no certificate, no free port, or a backend
- // too old to read the variables all mean LAN transpose is unavailable,
- // never that StemDeck fails to start.
- let https = certs::ensure(&data_dir)
+ let spawned = spawn_backend(app_handle, configured_port(), false);
+ store_backend(state, spawned)
+}
+
+/// Stops the backend and starts it again, so a change to "Make StemDeck
+/// available on your network" takes effect: the new backend listens where
+/// settings.json now says (#859). It takes nothing from the page, which can
+/// only save the setting and ask for this. The same port is asked for again,
+/// because the page's origin, and with it everything it keeps in
+/// localStorage, is that port.
+///
+/// Off the main thread: the stop, the port wait and the health wait take
+/// seconds, and the window has to keep painting the dialog meanwhile.
+#[tauri::command]
+async fn restart_backend(app_handle: tauri::AppHandle) -> Result {
+ tauri::async_runtime::spawn_blocking(move || {
+ let state = app_handle.state::();
+ restart_backend_blocking(&app_handle, &state)
+ })
+ .await
+ .map_err(|e| format!("the restart did not finish: {e}"))?
+}
+
+fn restart_backend_blocking(
+ app_handle: &tauri::AppHandle,
+ state: &BackendState,
+) -> Result {
+ let port = {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ if inner.starting {
+ return Err("Backend startup already in progress".to_string());
+ }
+ inner.starting = true;
+ inner.handles.as_ref().map(|h| h.port)
+ };
+ let spawned = stop_backend_and_wait(state, Duration::from_secs(15)).and_then(|()| {
+ let port = port.unwrap_or_else(configured_port);
+ if !wait_for_port_release(port, Duration::from_secs(5)) {
+ eprintln!("[stemdeck] port {port} is still held after the restart; using a free one");
+ }
+ spawn_backend(app_handle, port, true)
+ });
+ store_backend(state, spawned)
+}
+
+/// Waits until nothing holds `port` on loopback or on every interface, for at
+/// most `timeout`. stop_backend_and_wait returns once the process it started
+/// has exited, but on Windows the interpreter the venv launcher started ends
+/// when the backend's job closes, which is not instant, and holds the port
+/// until then.
+fn wait_for_port_release(port: u16, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if claim_port_on_both(ALL_INTERFACES, port, true).is_ok() {
+ return true;
+ }
+ if Instant::now() >= deadline {
+ return false;
+ }
+ thread::sleep(Duration::from_millis(100));
+ }
+}
+
+/// Clears the starting flag whether the spawn succeeded or failed, and keeps
+/// the new backend's handles.
+fn store_backend(
+ state: &BackendState,
+ spawned: Result,
+) -> Result {
+ let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
+ inner.starting = false;
+ let handles = spawned?;
+ let url = handles.url.clone();
+ inner.handles = Some(handles);
+ Ok(BackendStarted { url })
+}
+
+/// Spawns the backend on `preferred_port` (or a free port when that is
+/// taken) and waits for it to answer /api/health. `after_stop`: see
+/// [`claim_port`].
+fn spawn_backend(
+ app_handle: &tauri::AppHandle,
+ preferred_port: u16,
+ after_stop: bool,
+) -> Result {
+ // Loopback only while network access is off, so other devices cannot even
+ // connect; every interface once it is on (#859). The backend's network
+ // gate still refuses other devices whenever the setting is off, which
+ // covers the time between saving the choice and the restart that applies
+ // it. The WebView itself connects via 127.0.0.1 regardless.
+ let bind_host = configured_bind_host();
+ let root = app_root()?;
+ let backend_dir = backend_dir(&root)?;
+ let data_dir = local_data_dir()?;
+ let python = python_path(&root).filter(|p| p.is_file()).ok_or_else(|| {
+ "Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string()
+ })?;
+ patch_pyvenv_cfg(&python);
+ let (port, port_guard) = reserve_port(bind_host, preferred_port, after_stop)?;
+ // The LAN half of the pair, so only while the backend is on the LAN at
+ // all. The webview talks plain http to the loopback listener above,
+ // because a self-signed certificate would raise an interstitial a Tauri
+ // window has no way to click through; phones get this one, because
+ // without TLS their origin is insecure and the browser withholds the
+ // AudioWorklet transpose runs on. Best-effort throughout: no certificate,
+ // no free port, or a backend too old to read the variables all mean LAN
+ // transpose is unavailable, never that StemDeck fails to start.
+ let https = if bind_host == ALL_INTERFACES {
+ certs::ensure(&data_dir)
.map_err(|e| {
eprintln!("stemdeck: no LAN certificate, https disabled: {e}");
e
})
.ok()
.and_then(|cert| {
- reserve_port(bind_host, HTTPS_PORT)
+ reserve_port(bind_host, HTTPS_PORT, after_stop)
.map_err(|e| eprintln!("stemdeck: no port for https: {e}"))
.ok()
.map(|(https_port, guard)| (cert, https_port, guard))
- });
- let url = format!("http://127.0.0.1:{port}");
- let log_path = data_dir.join("logs").join("backend.log");
- let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| {
- // Logging should help diagnose startup; it should not prevent startup.
- (Stdio::null(), Stdio::null())
- });
-
- // On macOS and Linux, python-build-standalone detects its own prefix by
- // walking up from bin/ — PYTHONHOME is not needed and actively breaks
- // startup when mis-computed (it would point at python/bin, whose
- // lib/python3.X has no stdlib, so even `encodings` fails to import).
- // Only Windows, whose portable venv keeps the stdlib under base/Lib,
- // needs PYTHONHOME to locate the bundled stdlib.
- // Compute before moving python into Command::new.
- #[cfg(windows)]
- let pythonhome = python
- .parent()
- .and_then(|bin_dir| bin_dir.parent().map(|venv| (venv, bin_dir)))
- .and_then(|(venv, bin_dir)| bundled_python_home(venv, bin_dir).map(|(home, _)| home));
-
- let instance_token = new_instance_token();
- let mut cmd = Command::new(python);
- cmd.args([
- "-m",
- "uvicorn",
- "app.main:app",
- "--host",
- bind_host,
- "--port",
- &port.to_string(),
- // Bound how long uvicorn waits for open connections on shutdown.
- // The import queue's SSE stream stays open for as long as the app
- // window is on screen, and uvicorn drains connections before it
- // runs the lifespan teardown -- so without this the backend never
- // finishes draining, we escalate to SIGKILL below, and the teardown
- // that reaps the demucs worker never runs. Kept under the 3 s
- // SIGKILL deadline so the clean path wins.
- "--timeout-graceful-shutdown",
- "2",
- ]);
- #[cfg(windows)]
- if let Some(ref pythonhome) = pythonhome {
- cmd.env("PYTHONHOME", pythonhome);
- }
-
- // Jobs (stem audio files) live in ~/Documents/StemDeck/jobs/ so the user's
- // library is visible in Finder, backed up by iCloud, and survives app reinstalls.
- let jobs_dir = documents_dir_for_jobs(&app_handle);
-
- cmd.current_dir(&backend_dir)
- .env("STEMDECK_DATA_DIR", &data_dir)
- .env("STEMDECK_DEFAULT_JOBS_DIR", &jobs_dir)
- .env("STEMDECK_DESKTOP", "1")
- // Where the backend keeps the per-user copy of settings.json that
- // survives extracting a new package into a fresh folder. Computed
- // here so the write half and ensure_workspace's restore half can
- // never point at different places.
- .envs(
- shared_settings_dir()
- .map(|dir| ("STEMDECK_SETTINGS_MIRROR", dir.join("settings.json"))),
- )
- .env("STEMDECK_PARENT_PID", std::process::id().to_string())
- // How the backend proves it is ours when it answers /api/health.
- // The environment is the only channel that survives the Windows
- // venv launcher re-execing into python/base (#457), which is why
- // this exists rather than a PID comparison. See wait_for_health.
- .env("STEMDECK_INSTANCE_TOKEN", &instance_token)
- .env("PYTHONUNBUFFERED", "1")
- .env("XDG_CACHE_HOME", data_dir.join("cache"))
- .env("TORCH_HOME", data_dir.join("models").join("torch"))
- .stdout(stdout)
- .stderr(stderr);
-
- // app/core/tls_listener reads these three together; any one missing
- // means it stays off and the app is exactly what it was before.
- if let Some((ref cert, https_port, _)) = https {
- cmd.env("STEMDECK_SSL_CERT", &cert.cert)
- .env("STEMDECK_SSL_KEY", &cert.key)
- .env("STEMDECK_HTTPS_PORT", https_port.to_string());
- }
-
- apply_ffmpeg_path(&mut cmd, &data_dir)?;
- // fpcalc for song fingerprinting, fetched in the background once the
- // FFmpeg it stands in for is settled. Never waited for.
- #[cfg(unix)]
- spawn_fpcalc_setup(data_dir.clone());
+ })
+ } else {
+ None
+ };
+ let url = format!("http://127.0.0.1:{port}");
+ let log_path = data_dir.join("logs").join("backend.log");
+ let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| {
+ // Logging should help diagnose startup; it should not prevent startup.
+ (Stdio::null(), Stdio::null())
+ });
- #[cfg(windows)]
- {
- use std::os::windows::process::CommandExt;
- const CREATE_NO_WINDOW: u32 = 0x08000000;
- cmd.creation_flags(CREATE_NO_WINDOW);
- }
+ // On macOS and Linux, python-build-standalone detects its own prefix by
+ // walking up from bin/, so PYTHONHOME is not needed and actively breaks
+ // startup when mis-computed (it would point at python/bin, whose
+ // lib/python3.X has no stdlib, so even `encodings` fails to import).
+ // Only Windows, whose portable venv keeps the stdlib under base/Lib,
+ // needs PYTHONHOME to locate the bundled stdlib.
+ // Compute before moving python into Command::new.
+ #[cfg(windows)]
+ let pythonhome = python
+ .parent()
+ .and_then(|bin_dir| bin_dir.parent().map(|venv| (venv, bin_dir)))
+ .and_then(|(venv, bin_dir)| bundled_python_home(venv, bin_dir).map(|(home, _)| home));
- let mut child = cmd
- .spawn()
- .map_err(|e| format!("failed to start backend: {e}"))?;
- // Release the reserved ports immediately after spawn so uvicorn can
- // bind them. Both, or the companion listener finds its own port held.
- drop(port_guard);
- drop(https);
+ let instance_token = new_instance_token();
+ let mut cmd = Command::new(python);
+ cmd.args([
+ "-m",
+ "uvicorn",
+ "app.main:app",
+ "--host",
+ bind_host,
+ "--port",
+ &port.to_string(),
+ // Bound how long uvicorn waits for open connections on shutdown.
+ // The import queue's SSE stream stays open for as long as the app
+ // window is on screen, and uvicorn drains connections before it
+ // runs the lifespan teardown -- so without this the backend never
+ // finishes draining, we escalate to SIGKILL below, and the teardown
+ // that reaps the demucs worker never runs. Kept under the 3 s
+ // SIGKILL deadline so the clean path wins.
+ "--timeout-graceful-shutdown",
+ "2",
+ ]);
+ #[cfg(windows)]
+ if let Some(ref pythonhome) = pythonhome {
+ cmd.env("PYTHONHOME", pythonhome);
+ }
- if let Err(err) = wait_for_health(
- &mut child,
- port,
- &instance_token,
- Duration::from_secs(90),
- &log_path,
- ) {
- let _ = child.kill();
- let _ = child.wait();
- return Err(err);
- }
+ // The default jobs (stem audio) folder: see documents_dir_for_jobs.
+ let jobs_dir = documents_dir_for_jobs(app_handle);
- Ok((child, url))
- })();
+ cmd.current_dir(&backend_dir)
+ .env("STEMDECK_DATA_DIR", &data_dir)
+ .env("STEMDECK_DEFAULT_JOBS_DIR", &jobs_dir)
+ .env("STEMDECK_DESKTOP", "1")
+ // Reported back by /api/settings as network_listening, so the
+ // switch shows what is listening rather than what was saved.
+ .env("STEMDECK_BIND_HOST", bind_host)
+ .env("STEMDECK_PARENT_PID", std::process::id().to_string())
+ // How the backend proves it is ours when it answers /api/health.
+ // The environment is the only channel that survives the Windows
+ // venv launcher re-execing into python/base (#457), which is why
+ // this exists rather than a PID comparison. See wait_for_health.
+ .env("STEMDECK_INSTANCE_TOKEN", &instance_token)
+ .env("PYTHONUNBUFFERED", "1")
+ .env("XDG_CACHE_HOME", data_dir.join("cache"))
+ .env("TORCH_HOME", data_dir.join("models").join("torch"))
+ .stdout(stdout)
+ .stderr(stderr);
+ // The AMD GPU setup verified, for the backend and every worker it starts.
+ #[cfg(target_os = "linux")]
+ show_rocm_the_chosen_gpu(&mut cmd);
+
+ // app/core/tls_listener reads these three together; any one missing
+ // means it stays off and the app is exactly what it was before. Removed
+ // otherwise, so a copy in the user's own environment cannot start the
+ // LAN listener while network access is off.
+ if let Some((ref cert, https_port, _)) = https {
+ cmd.env("STEMDECK_SSL_CERT", &cert.cert)
+ .env("STEMDECK_SSL_KEY", &cert.key)
+ .env("STEMDECK_HTTPS_PORT", https_port.to_string());
+ } else {
+ cmd.env_remove("STEMDECK_SSL_CERT")
+ .env_remove("STEMDECK_SSL_KEY")
+ .env_remove("STEMDECK_HTTPS_PORT");
+ }
- // Atomically update state: clear starting flag whether spawn succeeded or failed.
- let mut inner = state.inner.lock().map_err(|e| e.to_string())?;
- inner.starting = false;
- match spawn_result {
- Ok((child, url)) => {
- inner.handles = Some(BackendHandles {
- child,
- url: url.clone(),
- });
- Ok(BackendStarted { url })
- }
- Err(e) => Err(e),
+ apply_ffmpeg_path(&mut cmd, &data_dir)?;
+ // fpcalc for song fingerprinting, fetched in the background once the
+ // FFmpeg it stands in for is settled. Never waited for.
+ #[cfg(unix)]
+ spawn_fpcalc_setup(data_dir.clone());
+
+ // On Windows also without a console window, and inside a job that
+ // ends the whole backend tree with StemDeck (see backendjob.rs).
+ let (mut child, job) =
+ backendjob::spawn_in_job(&mut cmd).map_err(|e| format!("failed to start backend: {e}"))?;
+ // Release the reserved ports immediately after spawn so uvicorn can
+ // bind them. Both, or the companion listener finds its own port held.
+ drop(port_guard);
+ drop(https);
+
+ if let Err(err) = wait_for_health(
+ &mut child,
+ port,
+ &instance_token,
+ Duration::from_secs(90),
+ &log_path,
+ ) {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(err);
}
+
+ Ok(BackendHandles {
+ child,
+ url,
+ port,
+ _job: job,
+ })
}
/// Reports the running build's OS/arch/GPU variant so the frontend can pick the
@@ -1936,29 +2483,20 @@ fn build_target() -> BuildTarget {
BuildTarget { os, arch, gpu }
}
-/// Best-effort primary LAN IPv4, shown in Settings so the user knows the address
-/// to open StemDeck from another device. Uses the "connect a UDP socket" trick:
-/// no packets are sent — connect() just makes the OS pick the source IP for the
-/// default route. Returns None when offline / no route.
+/// Detects GPU hardware, installs CUDA torch if needed, and persists the chosen device.
#[tauri::command]
-fn local_ip() -> Option {
- use std::net::UdpSocket;
- let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
- sock.connect("8.8.8.8:80").ok()?;
- match sock.local_addr().ok()?.ip() {
- std::net::IpAddr::V4(v4) if !v4.is_loopback() => Some(v4.to_string()),
- _ => None,
- }
+async fn ensure_torch_device(app: tauri::AppHandle) -> Result {
+ // A CUDA torch install moves several GB through pip.
+ off_main_thread(move || set_up_torch_device(&app.state(), &app)).await
}
-/// Detects GPU hardware, installs CUDA torch if needed, and persists the chosen device.
-#[tauri::command]
-fn ensure_torch_device(
- state: tauri::State,
+fn set_up_torch_device(
+ state: &BackendState,
// Carried so the pip passes below can report progress to the setup screen.
// macOS never runs them, hence the explicit discard in that branch.
- app: tauri::AppHandle,
+ app: &tauri::AppHandle,
) -> Result {
+ let _step = SetupStep::take(state, "GPU step")?;
log_setup_step("gpu-setup");
let root = app_root()?;
let data_dir = local_data_dir()?;
@@ -1973,6 +2511,7 @@ fn ensure_torch_device(
return Ok(GpuSetup {
gpu_detected: false,
gpu_name: None,
+ backend: None,
cuda_version: None,
torch_device: "cpu".to_string(),
cuda_verified: false,
@@ -1988,8 +2527,8 @@ fn ensure_torch_device(
#[cfg(target_os = "macos")]
{
// No pip pass on this platform: MPS ships in the bundled wheel.
- let _ = &app;
- let mps_available = verify_mps_torch(&python);
+ let _ = app;
+ let mps_available = verify_mps_torch(&python, state);
let (device, reason) = if mps_available {
("mps", "mps")
} else {
@@ -2003,6 +2542,7 @@ fn ensure_torch_device(
} else {
None
},
+ backend: None,
cuda_version: None,
torch_device: device.to_string(),
cuda_verified: false,
@@ -2012,13 +2552,14 @@ fn ensure_torch_device(
#[cfg(not(target_os = "macos"))]
{
- let site = torch_site_packages(&python);
+ let site = torch_site_packages(&python, state);
if site.is_none() {
append_to_setup_log(
&data_dir,
"could not locate site-packages; torch swaps overlay",
);
}
+ still_open(state)?;
// A swap that was interrupted (crash, forced quit, a rollback that did
// not finish) is put back before anything else looks at torch.
if let Some(site) = site.as_deref().filter(|s| torchswap::has_backup(s)) {
@@ -2033,13 +2574,50 @@ fn ensure_torch_device(
),
}
}
- let setup = match detect_nvidia_gpu(&data_dir) {
- Some((gpu_name, cuda_version, compute_cap)) => {
+ let detected =
+ detect_nvidia_gpu(&data_dir, state).map(|(name, cuda_version, compute_cap)| {
+ DetectedGpu {
+ candidates: wheel_candidates(compute_cap.as_deref(), &cuda_version),
+ name,
+ backend: "cuda",
+ version: cuda_version,
+ unusable: None,
+ }
+ });
+ // Without an NVIDIA GPU, an AMD one a ROCm build of torch can drive.
+ // ROCm torch exists for Linux only. Both builds serve the GPU as
+ // torch's "cuda" device, so everything after detection is the same.
+ #[cfg(target_os = "linux")]
+ let detected = detected.or_else(|| detect_amd_gpu(Path::new("/"), &data_dir));
+ let setup = match detected {
+ // Found, but no build StemDeck installs can use it: say why, and
+ // leave the bundled torch alone.
+ Some(DetectedGpu {
+ name,
+ backend,
+ version,
+ unusable: Some(reason),
+ ..
+ }) => GpuSetup {
+ gpu_detected: true,
+ gpu_name: Some(name),
+ backend: Some(backend.to_string()),
+ cuda_version: Some(version),
+ torch_device: "cpu".to_string(),
+ cuda_verified: false,
+ reason: reason.to_string(),
+ },
+ Some(DetectedGpu {
+ name: gpu_name,
+ backend,
+ version: cuda_version,
+ candidates,
+ unusable: None,
+ }) => {
// Try each candidate wheel in turn. A build that installs but
// cannot launch a kernel is not a reason to give up on the GPU
// if another build might work -- that was the whole failure in
// #502, where cu128 was the only thing ever offered.
- let candidates = wheel_candidates(compute_cap.as_deref(), &cuda_version);
let mut cuda_verified = false;
// Whether any candidate got far enough to be verified at all.
// Without this the reason below says "cuda-verify-failed" for a
@@ -2055,16 +2633,17 @@ fn ensure_torch_device(
let mut swapped = false;
// A re-run (a new app version) on a CUDA build that already
// works has nothing to install.
- let already = verify_cuda_torch(&python) == CudaVerify::Verified;
+ let already = verify_cuda_torch(&python, state) == CudaVerify::Verified;
if already {
append_to_setup_log(&data_dir, "CUDA torch already installed and verified");
cuda_verified = true;
}
for tag in candidates.iter().filter(|_| !already) {
+ still_open(state)?;
append_to_setup_log(
&data_dir,
&format!(
- "trying CUDA wheel {tag} (torch {})",
+ "trying GPU wheel {tag} (torch {})",
torch_version_for_tag(tag)
),
);
@@ -2094,13 +2673,13 @@ fn ensure_torch_device(
}
}
let index_url = format!("https://download.pytorch.org/whl/{tag}");
- if let Err(e) = install_cuda_torch(&python, &index_url, &state, &app) {
+ if let Err(e) = install_cuda_torch(&python, &index_url, state, app) {
append_to_setup_log(&data_dir, &format!("{tag} install failed: {e}"));
outcomes.push(None);
continue;
}
cuda_installed = true;
- let outcome = verify_cuda_torch(&python);
+ let outcome = verify_cuda_torch(&python, state);
outcomes.push(Some(outcome));
if outcome == CudaVerify::Verified {
append_to_setup_log(&data_dir, &format!("{tag} verified"));
@@ -2109,6 +2688,8 @@ fn ensure_torch_device(
}
append_to_setup_log(&data_dir, &format!("{tag} installed but did not verify"));
}
+ // Left as it is, a swap is put back on the next launch.
+ still_open(state)?;
let reason = if cuda_verified {
if swapped {
if let Some(site) = &site {
@@ -2132,8 +2713,8 @@ fn ensure_torch_device(
&python,
site.as_deref(),
swapped,
- &state,
- &app,
+ state,
+ app,
&data_dir,
) {
Ok(()) => stage,
@@ -2153,6 +2734,7 @@ fn ensure_torch_device(
GpuSetup {
gpu_detected: true,
gpu_name: Some(gpu_name),
+ backend: Some(backend.to_string()),
cuda_version: Some(cuda_version),
torch_device: if cuda_verified { "cuda" } else { "cpu" }.to_string(),
cuda_verified,
@@ -2162,6 +2744,7 @@ fn ensure_torch_device(
None => GpuSetup {
gpu_detected: false,
gpu_name: None,
+ backend: None,
cuda_version: None,
torch_device: "cpu".to_string(),
cuda_verified: false,
@@ -2172,6 +2755,7 @@ fn ensure_torch_device(
// cpu on a cpu-only package) as settled -- a CPU result born from a
// failure is re-probed on the next launch instead of pinning the
// install to CPU forever (#247).
+ still_open(state)?;
persist_torch_device(&data_dir, &setup.torch_device, &setup.reason);
append_to_setup_log(
&data_dir,
@@ -2201,6 +2785,97 @@ fn is_portable_package(root: &Path) -> bool {
root.join("portable.txt").is_file()
}
+/// Where a portable package keeps its WebView2 profile: in its data dir, like
+/// everything else it writes (#399). `None` keeps the platform default, which
+/// on Windows is %LOCALAPPDATA%\app.stemdeck.desktop\EBWebView.
+fn portable_webview_dir(root: &Path, data_dir: &Path) -> Option {
+ is_portable_package(root).then(|| data_dir.join("webview"))
+}
+
+/// The environment a portable package runs with, so that what other software
+/// writes to the user profile stays in its data dir too. Empty for anything
+/// else: an installed StemDeck keeps the system's locations.
+///
+/// - TEMP, TMP: the Windows temp folder. WebView2, pip's unpacking and
+/// Python's tempfile all write there; an upload over 1 MB is spooled there
+/// before the backend sees it.
+/// - TMPDIR: first in Python's tempfile search, ahead of the two above.
+/// - PIP_CACHE_DIR: the NVIDIA setup's CUDA torch install, whose wheels pip
+/// would otherwise cache in %LOCALAPPDATA%\pip.
+/// - MPLCONFIGDIR: matplotlib's font cache, otherwise %LOCALAPPDATA%\matplotlib.
+fn portable_env(root: &Path, data_dir: &Path) -> Vec<(&'static str, PathBuf)> {
+ if !is_portable_package(root) {
+ return Vec::new();
+ }
+ let tmp = data_dir.join("tmp");
+ let cache = data_dir.join("cache");
+ vec![
+ ("TEMP", tmp.clone()),
+ ("TMP", tmp.clone()),
+ ("TMPDIR", tmp),
+ ("PIP_CACHE_DIR", cache.join("pip")),
+ ("MPLCONFIGDIR", cache.join("matplotlib")),
+ ]
+}
+
+/// Applies `portable_env` to this process, which every child inherits: the
+/// backend, the model warmup, pip and WebView2.
+///
+/// Edition 2021: set_var is safe to call, but it is only sound while no other
+/// thread reads the environment, so this runs first thing in main.
+fn apply_portable_env() {
+ let (Ok(root), Ok(data_dir)) = (app_root(), local_data_dir()) else {
+ return;
+ };
+ let vars = portable_env(&root, &data_dir);
+ // The temp folder has to exist: Python's tempfile passes over one that
+ // does not, to the next candidate. Without it nothing is redirected, and
+ // setup.log says why.
+ if let Some((_, tmp)) = vars.iter().find(|(name, _)| *name == "TEMP") {
+ if let Err(e) = fs::create_dir_all(tmp) {
+ append_to_setup_log(
+ &data_dir,
+ &format!(
+ "could not create {}, temp files and tool caches go to the user profile: {e}",
+ tmp.display()
+ ),
+ );
+ return;
+ }
+ }
+ let _ = USER_ENV.set(
+ vars.iter()
+ .map(|(name, _)| (*name, env::var_os(name)))
+ .collect(),
+ );
+ for (name, value) in vars {
+ env::set_var(name, value);
+ }
+}
+
+/// The values `apply_portable_env` replaced, as the user had them.
+static USER_ENV: OnceLock)>> = OnceLock::new();
+
+/// Gives a program started for the user (Explorer, and the browser it may
+/// start) the user's own temp folder and caches, not the portable package's:
+/// a browser started cold from it would keep them for its whole session.
+fn with_user_env(command: &mut Command) -> &mut Command {
+ restore_env(command, USER_ENV.get().map_or(&[], Vec::as_slice))
+}
+
+fn restore_env<'a>(
+ command: &'a mut Command,
+ saved: &[(&'static str, Option)],
+) -> &'a mut Command {
+ for (name, value) in saved {
+ match value {
+ Some(value) => command.env(name, value),
+ None => command.env_remove(name),
+ };
+ }
+ command
+}
+
/// A persisted "cpu-only-package" device decision is only trustworthy while the
/// *current* install is still the CPU-only build. When a user replaces the CPU
/// package with the NVIDIA (CUDA) build in the same data dir, the leftover
@@ -2252,7 +2927,7 @@ fn persist_torch_device(data_dir: &std::path::Path, device: &str, reason: &str)
}
#[cfg(target_os = "macos")]
-fn verify_mps_torch(python: &Path) -> bool {
+fn verify_mps_torch(python: &Path, state: &BackendState) -> bool {
// Bounded for the same reason as verify_cuda_torch: a probe that imports
// torch and asks the GPU a question can wedge, and an unbounded wait there
// stops setup with nothing written anywhere (#502). Nothing has been seen
@@ -2266,7 +2941,7 @@ fn verify_mps_torch(python: &Path) -> bool {
])
.stdout(Stdio::null())
.stderr(Stdio::null());
- command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "MPS verify")
+ setup_probe_output(command, state, GPU_VERIFY_TIMEOUT, "MPS verify")
.map(|out| out.status.success())
.unwrap_or(false)
}
@@ -2323,17 +2998,19 @@ fn nvidia_smi_exe() -> String {
}
#[cfg(not(target_os = "macos"))]
-fn detect_nvidia_gpu(data_dir: &Path) -> Option<(String, String, Option)> {
+fn detect_nvidia_gpu(
+ data_dir: &Path,
+ state: &BackendState,
+) -> Option<(String, String, Option)> {
let smi = nvidia_smi_exe();
append_to_setup_log(data_dir, &format!("GPU detection using: {smi}"));
let mut cmd = Command::new(&smi);
cmd.args(["--query-gpu=name", "--format=csv,noheader"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
- hide_console_window(&mut cmd);
// 30s (vs 10s elsewhere): the first nvidia-smi call can be slow on Optimus
// laptops that have to wake a sleeping dGPU (#247).
- let name_out = match command_output_with_timeout(cmd, Duration::from_secs(30), "nvidia-smi") {
+ let name_out = match setup_probe_output(cmd, state, Duration::from_secs(30), "nvidia-smi") {
Ok(out) => out,
Err(e) => {
append_to_setup_log(data_dir, &format!("nvidia-smi failed to run: {e}"));
@@ -2359,29 +3036,29 @@ fn detect_nvidia_gpu(data_dir: &Path) -> Option<(String, String, Option)
// Read CUDA version from the standard nvidia-smi header.
let mut smi_cmd = Command::new(&smi);
smi_cmd.stdout(Stdio::piped()).stderr(Stdio::null());
- hide_console_window(&mut smi_cmd);
- let smi_out =
- command_output_with_timeout(smi_cmd, Duration::from_secs(10), "nvidia-smi").ok()?;
+ let smi_out = setup_probe_output(smi_cmd, state, Duration::from_secs(10), "nvidia-smi").ok()?;
let smi_text = String::from_utf8_lossy(&smi_out.stdout);
- let cuda_version = parse_cuda_version(&smi_text).unwrap_or_else(|| "12.4".to_string());
+ let cuda_version = parse_cuda_version(&smi_text).unwrap_or_else(|| {
+ append_to_setup_log(data_dir, &no_cuda_header_note(&smi_text));
+ "12.4".to_string()
+ });
// Read the GPU's compute capability (e.g. "12.0" for Blackwell sm_120,
// "8.9" for Ada). Drives the wheel choice: stock torch 2.6 cu12x wheels
// have no sm_120 kernels, so Blackwell needs a cu128 / torch 2.7 build.
// Failure here is non-fatal — we fall back to the CUDA-version heuristic.
- let compute_cap = detect_compute_cap(&smi);
+ let compute_cap = detect_compute_cap(&smi, state);
Some((gpu_name, cuda_version, compute_cap))
}
#[cfg(not(target_os = "macos"))]
-fn detect_compute_cap(smi: &str) -> Option {
+fn detect_compute_cap(smi: &str, state: &BackendState) -> Option {
let mut cmd = Command::new(smi);
cmd.args(["--query-gpu=compute_cap", "--format=csv,noheader"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
- hide_console_window(&mut cmd);
- let out = command_output_with_timeout(cmd, Duration::from_secs(10), "nvidia-smi").ok()?;
+ let out = setup_probe_output(cmd, state, Duration::from_secs(10), "nvidia-smi").ok()?;
if !out.status.success() {
return None;
}
@@ -2399,65 +3076,399 @@ fn detect_compute_cap(smi: &str) -> Option {
}
}
+/// The driver's CUDA version from the nvidia-smi header: the first
+/// "CUDA Version:" or "CUDA UMD Version:" (driver 616 and later) followed by a
+/// major.minor number. "N/A" is no version. The twin of `_CUDA_HEADER` in
+/// app/core/device_reason.py: keep the two one rule, and both tests on
+/// tests/fixtures/nvidia_smi_headers.json (#1012).
#[cfg(not(target_os = "macos"))]
fn parse_cuda_version(smi_output: &str) -> Option {
- for line in smi_output.lines() {
- if let Some(pos) = line.find("CUDA Version:") {
- let rest = &line[pos + "CUDA Version:".len()..];
- let v = rest.split_whitespace().next()?.trim_matches('|').trim();
- if !v.is_empty() && v != "N/A" {
- return Some(v.to_string());
- }
- }
- }
- None
+ let digits = |s: &str| s.len() - s.trim_start_matches(|c: char| c.is_ascii_digit()).len();
+ smi_output.match_indices("CUDA ").find_map(|(at, label)| {
+ let rest = &smi_output[at + label.len()..];
+ let rest = rest.strip_prefix("UMD ").unwrap_or(rest);
+ let number = rest.strip_prefix("Version:")?.trim_start();
+ let major = digits(number);
+ let minor = digits(number[major..].strip_prefix('.')?);
+ (major > 0 && minor > 0).then(|| number[..major + 1 + minor].to_string())
+ })
}
+/// A GPU setup can install a torch build for.
#[cfg(any(not(target_os = "macos"), test))]
-fn cuda_tag(cuda_version: &str) -> &'static str {
- let parts: Vec = cuda_version
- .splitn(2, '.')
- .filter_map(|p| p.parse().ok())
+struct DetectedGpu {
+ name: String,
+ /// "cuda" or "rocm". Either build serves the GPU as torch's "cuda" device.
+ backend: &'static str,
+ /// The driver's CUDA version, or the ROCm line the AMD build carries.
+ version: String,
+ /// Wheel tags to try, best first.
+ candidates: Vec<&'static str>,
+ /// Set when the GPU is there but no build can use it: the setup reason.
+ unusable: Option<&'static str>,
+}
+
+/// The ROCm wheel tag offered to AMD GPUs (#825). torch 2.8.0, like cu128, is
+/// the last line with torchaudio's soundfile backend (see
+/// `torch_version_for_tag`), and rocm6.4 is the newest ROCm index that line is
+/// published on. Its kernels include RDNA4; see ROCM_GFX_TARGETS.
+#[cfg(any(not(target_os = "macos"), test))]
+const ROCM_TAG: &str = "rocm6.4";
+
+/// The gfx targets torch 2.8.0+rocm6.4 carries kernels for, as
+/// `torch.cuda.get_arch_list()` reports them on that build. A card outside the
+/// list would download a wheel of several GB only to fail verification with
+/// "invalid device function", so it is never offered one.
+#[cfg(any(target_os = "linux", test))]
+const ROCM_GFX_TARGETS: &[&str] = &[
+ "gfx900", "gfx906", "gfx908", "gfx90a", "gfx942", "gfx1030", "gfx1100", "gfx1101", "gfx1102",
+ "gfx1200", "gfx1201",
+];
+
+/// Where the amdgpu driver lists its KFD topology, relative to the root.
+#[cfg(any(target_os = "linux", test))]
+const KFD_NODES: &str = "sys/class/kfd/kfd/topology/nodes";
+
+/// One GPU node of the amdgpu driver's KFD topology.
+#[cfg(any(target_os = "linux", test))]
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct KfdGpu {
+ /// Its place among the GPU nodes, in node order. That is the order the
+ /// ROCm runtime enumerates GPUs in, so it is the index ROCR_VISIBLE_DEVICES
+ /// takes.
+ ordinal: usize,
+ gfx: String,
+ /// Compute units times SIMDs per unit: a discrete card has many times an
+ /// integrated one's.
+ simd_count: u32,
+ device_id: u32,
+ render_minor: u32,
+}
+
+#[cfg(any(target_os = "linux", test))]
+impl KfdGpu {
+ fn has_rocm_kernels(&self) -> bool {
+ ROCM_GFX_TARGETS.contains(&self.gfx.as_str())
+ }
+}
+
+/// The gfx target name for a KFD `gfx_target_version`: major * 10000 +
+/// minor * 100 + stepping, with minor and stepping written in hex in the name,
+/// so 120001 is gfx1201 and the MI200's 90010 is gfx90a.
+#[cfg(any(target_os = "linux", test))]
+fn gfx_target_name(version: u32) -> String {
+ format!(
+ "gfx{}{:x}{:x}",
+ version / 10000,
+ (version / 100) % 100,
+ version % 100
+ )
+}
+
+/// One KFD node's `properties` file, as a GPU. None for a CPU node, which
+/// reports a gfx_target_version of 0. `ordinal` is filled in by the caller.
+#[cfg(any(target_os = "linux", test))]
+fn parse_kfd_node(properties: &str) -> Option {
+ let field = |key: &str| -> Option {
+ properties.lines().find_map(|line| {
+ let (k, v) = line.split_once(' ')?;
+ if k == key {
+ v.trim().parse().ok()
+ } else {
+ None
+ }
+ })
+ };
+ let gfx = field("gfx_target_version").filter(|v| *v != 0)?;
+ Some(KfdGpu {
+ ordinal: 0,
+ gfx: gfx_target_name(gfx),
+ simd_count: field("simd_count").unwrap_or(0),
+ device_id: field("device_id").unwrap_or(0),
+ render_minor: field("drm_render_minor").unwrap_or(0),
+ })
+}
+
+/// Every GPU node under `root`'s KFD topology, in node order. Node directories
+/// are numbers, and read_dir returns them in no particular order, so they are
+/// sorted as numbers: node 10 comes after node 9.
+#[cfg(any(target_os = "linux", test))]
+fn kfd_gpus(root: &Path) -> Vec {
+ let Ok(entries) = fs::read_dir(root.join(KFD_NODES)) else {
+ return Vec::new();
+ };
+ let mut nodes: Vec<(u32, String)> = entries
+ .flatten()
+ .filter_map(|entry| {
+ let node = entry.file_name().to_str()?.parse().ok()?;
+ Some((
+ node,
+ fs::read_to_string(entry.path().join("properties")).ok()?,
+ ))
+ })
.collect();
- match parts.as_slice() {
- // A CUDA 13 driver used to land in the catch-all below and be handed
- // cu124 -- older than the driver by two major versions, on every card,
- // not just Blackwell (#502). CUDA is backward compatible, so cu128 is
- // the right floor for a 13.x driver.
- [major, _] if *major >= 13 => "cu128",
- // Every CUDA 12 driver, not just 12.4 and newer. A 12.0-12.3 driver
- // used to be handed cu121, whose index stops at torch 2.5.1 -- so the
- // 2.6.0 line this app installs was asked for at a tag that has never
- // published it, pip answered "No matching distribution found", and an
- // RTX 4060 dropped to CPU with a GPU sitting right there (#644).
- // cu124 is the right answer for all of them: CUDA 12 is minor-version
- // compatible, so a cu124 build runs on any 12.x driver.
- [12, _] => "cu124",
- [11, _] => "cu118",
- _ => "cu124",
- }
+ nodes.sort_by_key(|(node, _)| *node);
+ nodes
+ .iter()
+ .filter_map(|(_, properties)| parse_kfd_node(properties))
+ .enumerate()
+ .map(|(ordinal, gpu)| KfdGpu { ordinal, ..gpu })
+ .collect()
}
-/// The tag `wheel_candidates` would try first.
-///
-/// Test-only since the candidates loop replaced the single-answer call site:
-/// setup now walks the whole list, so nothing in the app asks for just the
-/// head of it. Kept because the per-architecture expectations below are
-/// clearer read one tag at a time than as one-element vectors.
-#[cfg(test)]
-fn wheel_tag(compute_cap: Option<&str>, cuda_version: &str) -> &'static str {
- wheel_candidates(compute_cap, cuda_version)[0]
+/// The AMD GPU to use: one the ROCm build has kernels for before one it has
+/// none for, then the one with the most SIMDs, then the first. A Ryzen with
+/// its integrated GPU enabled lists that GPU too, often first, and its gfx
+/// target (gfx1036 and the like) has no kernels in the build: taking the first
+/// node used to settle such a machine on CPU with the discrete card unused.
+#[cfg(any(target_os = "linux", test))]
+fn choose_amd_gpu(gpus: &[KfdGpu]) -> Option<&KfdGpu> {
+ gpus.iter().max_by_key(|gpu| {
+ (
+ gpu.has_rocm_kernels(),
+ gpu.simd_count,
+ std::cmp::Reverse(gpu.ordinal),
+ )
+ })
}
-/// Wheel tags to try for this GPU, best first.
-///
-/// A list rather than one answer so a tag that installs but does not verify
-/// falls through to the next instead of dropping straight to CPU, and so the
-/// setup log names every tag that was tried. #502 had no such record: the user
-/// got no GPU and no explanation.
-///
-/// Blackwell (sm_100 / sm_120, major >= 10) has no kernels at all in the stock
-/// torch 2.6 cu12x wheels (#217), which is why it never falls through to the
+/// The ROCR_VISIBLE_DEVICES value that shows the ROCm runtime only the GPU
+/// `choose_amd_gpu` picks, or None when there is no AMD GPU the build can use.
+/// Without it a second, unsupported GPU stays visible: HIP can number it
+/// device 0, or refuse to start with hipErrorNoBinaryForGpu, "for all current
+/// devices".
+#[cfg(any(target_os = "linux", test))]
+fn rocm_visible_devices(root: &Path) -> Option {
+ let gpus = kfd_gpus(root);
+ choose_amd_gpu(&gpus)
+ .filter(|gpu| gpu.has_rocm_kernels())
+ .map(|gpu| gpu.ordinal.to_string())
+}
+
+/// Shows a torch process only the AMD GPU setup chose. Read off sysfs for
+/// every process rather than saved at setup, so a GPU added or removed later
+/// cannot leave a stale index behind. A choice the user made in the
+/// environment wins. Harmless for CUDA, which never reads it.
+#[cfg(target_os = "linux")]
+fn show_rocm_the_chosen_gpu(command: &mut Command) {
+ if let Some(devices) =
+ rocm_visible_devices_to_set(Path::new("/"), |name| env::var_os(name).is_some())
+ {
+ command.env("ROCR_VISIBLE_DEVICES", devices);
+ }
+}
+
+/// `rocm_visible_devices`, unless the user already chose which GPUs a
+/// process sees through any variable HIP reads (`user_set`), in which case
+/// adding ROCR_VISIBLE_DEVICES on top would renumber the devices under their
+/// choice. HIP reads CUDA_VISIBLE_DEVICES as well as its own.
+#[cfg(any(target_os = "linux", test))]
+fn rocm_visible_devices_to_set(root: &Path, user_set: impl Fn(&str) -> bool) -> Option {
+ let chosen_by_user = [
+ "ROCR_VISIBLE_DEVICES",
+ "HIP_VISIBLE_DEVICES",
+ "CUDA_VISIBLE_DEVICES",
+ ]
+ .into_iter()
+ .any(user_set);
+ if chosen_by_user {
+ None
+ } else {
+ rocm_visible_devices(root)
+ }
+}
+
+/// The marketing name for an AMD PCI device from a pci.ids database, such as
+/// "Radeon RX 9070/9070 XT/9070 GRE" out of
+/// "7550 Navi 48 [Radeon RX 9070/9070 XT/9070 GRE]". The bracketed part is the
+/// product name; without one the whole entry is used.
+#[cfg(any(target_os = "linux", test))]
+fn pci_ids_amd_name(pci_ids: &str, device_id: u32) -> Option {
+ let wanted = format!("{device_id:04x}");
+ let mut in_amd = false;
+ for line in pci_ids.lines() {
+ // Comments sit inside vendor sections, and must not end one.
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ if !line.starts_with('\t') {
+ in_amd = line.starts_with("1002 ");
+ continue;
+ }
+ if !in_amd || line.starts_with("\t\t") {
+ continue;
+ }
+ let Some(name) = line.trim_start_matches('\t').strip_prefix(wanted.as_str()) else {
+ continue;
+ };
+ let name = name.trim();
+ let product = match (name.find('['), name.rfind(']')) {
+ (Some(open), Some(close)) if open < close => &name[open + 1..close],
+ _ => name,
+ };
+ return Some(product.to_string());
+ }
+ None
+}
+
+/// What setup.log and the setup screen call `gpu`: the driver's product name,
+/// else the pci.ids entry, with the gfx target, which decides support.
+#[cfg(any(target_os = "linux", test))]
+fn amd_gpu_name(root: &Path, gpu: &KfdGpu) -> String {
+ let device = root
+ .join("sys/class/drm")
+ .join(format!("renderD{}", gpu.render_minor))
+ .join("device");
+ fs::read_to_string(device.join("product_name"))
+ .ok()
+ .map(|name| name.trim().to_string())
+ .filter(|name| !name.is_empty())
+ .or_else(|| {
+ ["usr/share/hwdata/pci.ids", "usr/share/misc/pci.ids"]
+ .iter()
+ .find_map(|ids| {
+ pci_ids_amd_name(&fs::read_to_string(root.join(ids)).ok()?, gpu.device_id)
+ })
+ })
+ .map_or_else(
+ || format!("AMD GPU ({})", gpu.gfx),
+ |product| format!("AMD {product} ({})", gpu.gfx),
+ )
+}
+
+/// Finds an AMD GPU through the amdgpu driver's KFD topology under `root`
+/// ("/" outside tests), the interface the ROCm runtime itself uses. Needs no
+/// ROCm install and runs nothing: the torch wheel carries the whole user-space
+/// runtime, so the kernel driver is all the machine has to provide.
+#[cfg(any(target_os = "linux", test))]
+fn detect_amd_gpu(root: &Path, data_dir: &Path) -> Option {
+ let gpus = kfd_gpus(root);
+ for gpu in &gpus {
+ append_to_setup_log(
+ data_dir,
+ &format!(
+ "KFD GPU {}: {}, {} SIMDs, PCI device {:04x}, renderD{}",
+ gpu.ordinal, gpu.gfx, gpu.simd_count, gpu.device_id, gpu.render_minor
+ ),
+ );
+ }
+ let gpu = choose_amd_gpu(&gpus)?;
+ let name = amd_gpu_name(root, gpu);
+ append_to_setup_log(
+ data_dir,
+ &format!(
+ "AMD GPU chosen: {name}, KFD GPU {0}; torch processes get \
+ ROCR_VISIBLE_DEVICES={0} unless ROCR_, HIP_ or CUDA_VISIBLE_DEVICES is set",
+ gpu.ordinal
+ ),
+ );
+ let candidates = if gpu.has_rocm_kernels() {
+ vec![ROCM_TAG]
+ } else {
+ Vec::new()
+ };
+ // The ROCm runtime reaches the GPU through /dev/kfd and the card's render
+ // node, which most distributions give to the render group. Without access
+ // every build would install and then see no device.
+ let can_open = |path: PathBuf| {
+ fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(path)
+ .is_ok()
+ };
+ let kfd_ok = can_open(root.join("dev/kfd"))
+ && can_open(root.join(format!("dev/dri/renderD{}", gpu.render_minor)));
+ let unusable = if candidates.is_empty() {
+ append_to_setup_log(
+ data_dir,
+ &format!(
+ "no ROCm build StemDeck installs has kernels for {}; staying on CPU",
+ gpu.gfx
+ ),
+ );
+ // Settled by the setup screen: nothing on offer can change until an
+ // update brings a different build.
+ Some("rocm-unsupported-gpu")
+ } else if !kfd_ok {
+ append_to_setup_log(
+ data_dir,
+ "cannot open /dev/kfd or the GPU's render node; add your user to the render and video groups \
+ (sudo usermod -aG render,video $USER) and log in again",
+ );
+ // Not settled: a user who fixes the groups gets the GPU next launch.
+ Some("rocm-no-kfd-access")
+ } else {
+ None
+ };
+ Some(DetectedGpu {
+ name,
+ backend: "rocm",
+ version: ROCM_TAG.trim_start_matches("rocm").to_string(),
+ candidates,
+ unusable,
+ })
+}
+
+/// The setup.log line for an nvidia-smi header with no CUDA version, which
+/// leaves the wheel choice on its 12.4 default. It quotes the header line, so
+/// a report shows what the driver printed instead (#247).
+#[cfg(not(target_os = "macos"))]
+fn no_cuda_header_note(smi_output: &str) -> String {
+ let header = smi_output
+ .lines()
+ .find(|line| line.contains("NVIDIA-SMI"))
+ .map_or("none", |line| {
+ line.trim_matches(|c: char| c == '|' || c.is_whitespace())
+ });
+ format!("nvidia-smi header had no CUDA version; assuming CUDA 12.4. Header: {header}")
+}
+
+#[cfg(any(not(target_os = "macos"), test))]
+fn cuda_tag(cuda_version: &str) -> &'static str {
+ let parts: Vec = cuda_version
+ .splitn(2, '.')
+ .filter_map(|p| p.parse().ok())
+ .collect();
+ match parts.as_slice() {
+ // A CUDA 13 driver used to land in the catch-all below and be handed
+ // cu124 -- older than the driver by two major versions, on every card,
+ // not just Blackwell (#502). CUDA is backward compatible, so cu128 is
+ // the right floor for a 13.x driver.
+ [major, _] if *major >= 13 => "cu128",
+ // Every CUDA 12 driver, not just 12.4 and newer. A 12.0-12.3 driver
+ // used to be handed cu121, whose index stops at torch 2.5.1 -- so the
+ // 2.6.0 line this app installs was asked for at a tag that has never
+ // published it, pip answered "No matching distribution found", and an
+ // RTX 4060 dropped to CPU with a GPU sitting right there (#644).
+ // cu124 is the right answer for all of them: CUDA 12 is minor-version
+ // compatible, so a cu124 build runs on any 12.x driver.
+ [12, _] => "cu124",
+ [11, _] => "cu118",
+ _ => "cu124",
+ }
+}
+
+/// The tag `wheel_candidates` would try first.
+///
+/// Test-only since the candidates loop replaced the single-answer call site:
+/// setup now walks the whole list, so nothing in the app asks for just the
+/// head of it. Kept because the per-architecture expectations below are
+/// clearer read one tag at a time than as one-element vectors.
+#[cfg(all(test, not(target_os = "macos")))]
+fn wheel_tag(compute_cap: Option<&str>, cuda_version: &str) -> &'static str {
+ wheel_candidates(compute_cap, cuda_version)[0]
+}
+
+/// Wheel tags to try for this GPU, best first.
+///
+/// A list rather than one answer so a tag that installs but does not verify
+/// falls through to the next instead of dropping straight to CPU, and so the
+/// setup log names every tag that was tried. #502 had no such record: the user
+/// got no GPU and no explanation.
+///
+/// Blackwell (sm_100 / sm_120, major >= 10) has no kernels at all in the stock
+/// torch 2.6 cu12x wheels (#217), which is why it never falls through to the
/// driver heuristic. It stays on cu128 even under a CUDA 13 driver, which is
/// backward compatible and runs a cu12x build. cu130 is deliberately not
/// offered: it carries torch/torchaudio 2.9+ only, and torchaudio dropped its
@@ -2501,10 +3512,11 @@ fn wheel_candidates(compute_cap: Option<&str>, cuda_version: &str) -> Vec<&'stat
/// because 2.7.1 shipped them incomplete (#239). 2.8.0 is also the last line
/// that still carries torchaudio's soundfile backend, which is what demucs'
/// `ta.save()` needs -- see `wheel_candidates` for why that rules out cu130.
+/// ROCm takes the same line (#825).
#[cfg(any(not(target_os = "macos"), test))]
fn torch_version_for_tag(tag: &str) -> &'static str {
match tag {
- "cu128" => "2.8.0",
+ "cu128" | ROCM_TAG => "2.8.0",
_ => "2.6.0",
}
}
@@ -2521,19 +3533,100 @@ fn torch_version_for_tag(tag: &str) -> &'static str {
/// nothing updates it on our behalf. The lockfile pins 0.21.0 against torch
/// 2.6.0, which is why only the cu128 line, the one that moves torch to 2.8.0,
/// ever broke.
-// Not `any(not(macos), test)` like torch_version_for_tag above. That one is
-// reached by a test which itself runs everywhere, and returns literals. This
-// one returns CPU_TORCHVISION_VERSION, a `not(macos)` constant, and both tests
-// that call it are `not(macos)` too -- so compiling it into a macOS test build
-// asked for a constant that is not there, and `cargo test` could not build on
-// macOS at all. The workflow that would have caught it had been cancelled on
-// every recent run rather than failing, so it went unseen (#643).
-#[cfg(not(target_os = "macos"))]
+// `any(not(macos), test)`, and so are the constants it reads. A macOS test
+// build once compiled this while CPU_TORCHVISION_VERSION was `not(macos)` only,
+// and `cargo test` could not build on macOS at all (#643). Gating both the same
+// way lets the tests that pin them to packaging/torch-versions.json run on
+// every host (#778).
+#[cfg(any(not(target_os = "macos"), test))]
fn torchvision_version_for_tag(tag: &str) -> &'static str {
- match tag {
- "cu128" => "0.23.0",
- _ => CPU_TORCHVISION_VERSION,
+ torchvision_for_torch(torch_version_for_tag(tag)).unwrap_or(CPU_TORCHVISION_VERSION)
+}
+
+/// The torchvision built for each torch line the desktop shell installs. The
+/// same pairs as `torchvisionForTorch` in packaging/torch-versions.json, which
+/// the packaging scripts read; a test fails when the two differ.
+#[cfg(any(not(target_os = "macos"), test))]
+const TORCHVISION_FOR_TORCH: [(&str, &str); 2] = [("2.6.0", "0.21.0"), ("2.8.0", "0.23.0")];
+
+/// The torchvision for a torch public version ("2.6.0", no `+tag`), if known.
+#[cfg(any(not(target_os = "macos"), test))]
+fn torchvision_for_torch(torch: &str) -> Option<&'static str> {
+ TORCHVISION_FOR_TORCH
+ .iter()
+ .find(|(t, _)| *t == torch)
+ .map(|(_, vision)| *vision)
+}
+
+/// The torch, torchaudio and torchvision specs for one CUDA wheel tag. One
+/// place, so the pair a CUDA install asks for is the pair the tests check.
+#[cfg(any(not(target_os = "macos"), test))]
+fn cuda_torch_specs(tag: &str) -> [String; 3] {
+ let torch = torch_version_for_tag(tag);
+ let vision = torchvision_version_for_tag(tag);
+ [
+ format!("torch=={torch}+{tag}"),
+ format!("torchaudio=={torch}+{tag}"),
+ format!("torchvision=={vision}+{tag}"),
+ ]
+}
+
+/// The string out of a `__version__ = '0.21.0+cu124'` line in a package's
+/// version.py. torch and torchvision both write one. Reading it costs a file
+/// read, where asking Python means importing torchvision, the very thing that
+/// fails on a broken install (#778).
+#[cfg(any(not(target_os = "macos"), test))]
+fn version_from_version_py(src: &str) -> Option<&str> {
+ src.lines().find_map(|line| {
+ let rest = line.trim().strip_prefix("__version__")?;
+ // `__version__ = ...` or `__version__: str = ...`, not `__version_info__`.
+ if !rest.starts_with([' ', '\t', ':', '=']) {
+ return None;
+ }
+ let value = rest.split_once('=')?.1.trim();
+ let quote = value.chars().next().filter(|c| *c == '\'' || *c == '"')?;
+ let inner = &value[1..];
+ let version = &inner[..inner.find(quote)?];
+ (!version.is_empty()).then_some(version)
+ })
+}
+
+/// The installed version of `package` in `site`, from its version.py.
+#[cfg(any(not(target_os = "macos"), test))]
+fn installed_version(site: &Path, package: &str) -> Option {
+ let src = fs::read_to_string(site.join(package).join("version.py")).ok()?;
+ version_from_version_py(&src).map(str::to_string)
+}
+
+/// The pip spec that repairs a GPU (CUDA or ROCm) torchvision sitting beside
+/// CPU torch, or a missing torchvision beside it (a pip run that stopped
+/// between uninstall and install). `None` when there is nothing to repair.
+///
+/// That pairing is what the 0.20.0 Linux packages shipped: CPU torch, and the
+/// cu124 torchvision PyPI serves by default, whose compiled ops need CUDA
+/// libraries that are not there (#778). The in-app updater never replaces
+/// python/, so those installs keep it until something puts the CPU wheel in.
+///
+/// Decided from the torch beside it, not from the package variant. A CUDA
+/// torch with a CUDA torchvision is a working NVIDIA install and is left
+/// alone; an NVIDIA package that ended up on CPU torch (no GPU, or CUDA did
+/// not verify and the bundled torch went back) has the same broken pair as
+/// the CPU package. A torch line with no known torchvision is left alone
+/// rather than guessed at. Any build but `+cpu` is repaired, so a `+rocm6.4`
+/// torchvision left by an interrupted AMD install is too (#825).
+#[cfg(any(not(target_os = "macos"), test))]
+fn torchvision_repair_spec(torch: &str, torchvision: Option<&str>) -> Option {
+ let (torch_public, torch_tag) = torch.split_once('+')?;
+ if torch_tag != "cpu" {
+ return None;
+ }
+ if let Some(torchvision) = torchvision {
+ let (_, vision_tag) = torchvision.split_once('+')?;
+ if vision_tag == "cpu" {
+ return None;
+ }
}
+ torchvision_for_torch(torch_public).map(|vision| format!("torchvision=={vision}+cpu"))
}
#[cfg(not(target_os = "macos"))]
@@ -2709,6 +3802,7 @@ fn python_stdlib_present(venv_root: &Path) -> bool {
/// Maps known pip/OS failure patterns to actionable user messages.
/// Pure function — caller is responsible for logging the raw stderr before calling.
+#[cfg(not(target_os = "macos"))]
fn classify_cuda_install_error(stderr: &str) -> String {
let lower = stderr.to_ascii_lowercase();
@@ -2740,13 +3834,14 @@ fn classify_cuda_install_error(stderr: &str) -> String {
"CUDA install failed — see logs/setup.log for details.".to_string()
}
-/// Version of the CPU-only torch wheels baked into every package. Kept in sync
-/// with scripts/windows/make-portable.ps1 and scripts/linux/make-portable.sh.
-#[cfg(not(target_os = "macos"))]
+/// Version of the CPU-only torch wheels baked into every package: `cpuTorch`
+/// in packaging/torch-versions.json, which the packaging scripts read. A test
+/// fails when the two differ.
+#[cfg(any(not(target_os = "macos"), test))]
const CPU_TORCH_VERSION: &str = "2.6.0";
/// Matches CPU_TORCH_VERSION and the version uv.lock resolves. Move the two
/// together or the ops registration breaks; see torchvision_version_for_tag.
-#[cfg(not(target_os = "macos"))]
+#[cfg(any(not(target_os = "macos"), test))]
const CPU_TORCHVISION_VERSION: &str = "0.21.0";
/// What a line of pip's output means, for the setup screen.
@@ -2816,14 +3911,17 @@ struct SetupProgress {
total: Option,
}
-/// Whether the CUDA torch wheel needs its `nvidia-*` runtime dependencies
-/// installed separately. Linux CUDA wheels do not bundle the CUDA runtime —
-/// they dlopen libcublas/libcudnn/... out of the `nvidia-*` PyPI packages at
-/// import time. Windows wheels ship those DLLs inside torch/lib and declare no
-/// such dependencies, so the leaner --no-deps swap stays correct there (#324).
+/// Whether the GPU torch wheel for `tag` needs its `nvidia-*` runtime
+/// dependencies installed separately. Linux CUDA wheels do not bundle the CUDA
+/// runtime: they dlopen libcublas/libcudnn/... out of the `nvidia-*` PyPI
+/// packages at import time. Windows wheels ship those DLLs inside torch/lib and
+/// declare no such dependencies, so the leaner --no-deps swap stays correct
+/// there (#324). ROCm wheels carry HIP, MIOpen and rocBLAS inside torch/lib
+/// too; their one dependency, pytorch-triton-rocm, serves torch.compile, which
+/// StemDeck never calls (#825).
#[cfg(not(target_os = "macos"))]
-fn cuda_wheel_needs_runtime_deps() -> bool {
- cfg!(target_os = "linux")
+fn cuda_wheel_needs_runtime_deps(tag: &str) -> bool {
+ cfg!(target_os = "linux") && tag.starts_with("cu")
}
/// Builds the per-line callback `run_pip_install` hands to the reader thread.
@@ -2958,7 +4056,7 @@ const PIP_STALL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Backstop for a pip that never goes quiet but never finishes either. Nothing
/// legitimate approaches this: the reporter whose install prompted the change
/// took about 17 minutes for the whole 3 GB pass on a slow link. The window
-/// close handler kills the child through `setup_child_pid` long before here.
+/// close handler kills the child through `setup_child` long before here.
#[cfg(not(target_os = "macos"))]
const PIP_HARD_CAP: Duration = Duration::from_secs(4 * 60 * 60);
@@ -3002,26 +4100,16 @@ fn spawn_pip(
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
- hide_console_window(&mut command);
-
- let child = command
- .spawn()
- .map_err(|e| format!("failed to start {label}: {e}"))?;
- if let Ok(mut inner) = state.inner.lock() {
- inner.setup_child_pid = Some(child.id());
- }
- let output = child_output_streaming(
- child,
- stall,
- PIP_HARD_CAP,
- label,
- pip_line_reporter(app.clone(), label.to_string()),
- );
- if let Ok(mut inner) = state.inner.lock() {
- inner.setup_child_pid = None;
- }
- output
+ run_setup_child(&mut command, state, label, |child| {
+ child_output_streaming(
+ child,
+ stall,
+ PIP_HARD_CAP,
+ label,
+ pip_line_reporter(app.clone(), label.to_string()),
+ )
+ })
}
/// Whether pip refused `--progress-bar raw` itself, as opposed to failing at
@@ -3068,7 +4156,7 @@ fn restore_cpu_torch(
/// itself rather than assumed from the layout, which differs between the
/// Windows and Linux bundles and the downloaded runtime pack.
#[cfg(not(target_os = "macos"))]
-fn torch_site_packages(python: &Path) -> Option {
+fn torch_site_packages(python: &Path, state: &BackendState) -> Option {
let mut command = Command::new(python);
command
.args([
@@ -3077,10 +4165,14 @@ fn torch_site_packages(python: &Path) -> Option {
])
.stdout(Stdio::piped())
.stderr(Stdio::null());
- hide_console_window(&mut command);
- let out = command_output_with_timeout(command, Duration::from_secs(60), "site-packages probe")
- .ok()
- .filter(|out| out.status.success())?;
+ let out = setup_probe_output(
+ command,
+ state,
+ Duration::from_secs(60),
+ "site-packages probe",
+ )
+ .ok()
+ .filter(|out| out.status.success())?;
let path = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
path.is_dir().then_some(path)
}
@@ -3088,14 +4180,25 @@ fn torch_site_packages(python: &Path) -> Option {
/// Whether `import torch` works. A restore that leaves torch unimportable is
/// a failed restore, whatever pip said (#731).
#[cfg(not(target_os = "macos"))]
-fn verify_torch_imports(python: &Path) -> Result<(), String> {
+fn verify_torch_imports(python: &Path, state: &BackendState) -> Result<(), String> {
+ verify_imports(python, "torch", state)
+}
+
+/// Whether `import ` works, with the last lines of the error if not.
+#[cfg(not(target_os = "macos"))]
+fn verify_imports(python: &Path, module: &str, state: &BackendState) -> Result<(), String> {
+ let code = format!("import {module}");
let mut command = Command::new(python);
command
- .args(["-c", "import torch"])
+ .args(["-c", code.as_str()])
.stdout(Stdio::null())
.stderr(Stdio::piped());
- hide_console_window(&mut command);
- let out = command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "torch import check")?;
+ let out = setup_probe_output(
+ command,
+ state,
+ GPU_VERIFY_TIMEOUT,
+ &format!("{module} import check"),
+ )?;
if out.status.success() {
return Ok(());
}
@@ -3123,7 +4226,7 @@ fn put_cpu_torch_back(
) -> Result<(), String> {
if let (true, Some(site)) = (swapped, site) {
match torchswap::restore(site) {
- Ok(()) => match verify_torch_imports(python) {
+ Ok(()) => match verify_torch_imports(python, state) {
Ok(()) => {
append_to_setup_log(data_dir, "put the bundled torch back");
return Ok(());
@@ -3140,7 +4243,7 @@ fn put_cpu_torch_back(
}
}
restore_cpu_torch(python, state, app)?;
- let Err(first) = verify_torch_imports(python) else {
+ let Err(first) = verify_torch_imports(python, state) else {
return Ok(());
};
append_to_setup_log(
@@ -3151,12 +4254,136 @@ fn put_cpu_torch_back(
torchswap::discard_installed(site)
.map_err(|e| format!("could not clear the broken torch ({e})"))?;
restore_cpu_torch(python, state, app)?;
- verify_torch_imports(python)
+ verify_torch_imports(python, state)
.map_err(|e| format!("CPU torch does not import after a clean reinstall:\n{e}"))?;
append_to_setup_log(data_dir, "CPU torch reinstalled into clean directories");
Ok(())
}
+/// What the torchvision check on launch found (#778).
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct TorchvisionRepair {
+ /// "ok" (nothing to do), "repaired", or "failed". The setup screen owns
+ /// the wording; the reason for a failure is in setup.log.
+ outcome: &'static str,
+}
+
+/// Puts the CPU torchvision back on an install that has a CUDA torchvision
+/// beside CPU torch, which is what the 0.20.0 Linux packages shipped (#778).
+///
+/// Runs on every launch, because the in-app updater never replaces python/:
+/// an install that updated from 0.20.0 keeps the broken wheel until something
+/// here replaces it. On a healthy install it asks the interpreter where
+/// site-packages is (no imports), reads two version.py files and returns,
+/// with no network. A failed repair (offline, say) is logged and
+/// reported, never raised: the rest of the app works without torchvision, and
+/// the next launch tries again.
+#[tauri::command]
+async fn repair_torchvision(app: tauri::AppHandle) -> Result {
+ #[cfg(target_os = "macos")]
+ {
+ // macOS ships one torch build and never pairs it with a CUDA wheel.
+ let _ = app;
+ Ok(TorchvisionRepair { outcome: "ok" })
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ // The repair is a pip download.
+ off_main_thread(move || {
+ let state = app.state();
+ let _step = SetupStep::take(&state, "torchvision repair")?;
+ Ok(TorchvisionRepair {
+ outcome: repair_torchvision_if_needed(&state, &app),
+ })
+ })
+ .await
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn repair_torchvision_if_needed(state: &BackendState, app: &tauri::AppHandle) -> &'static str {
+ let (Ok(root), Ok(data_dir)) = (app_root(), local_data_dir()) else {
+ return "ok";
+ };
+ let Some(python) = python_path(&root).filter(|p| p.is_file()) else {
+ return "ok";
+ };
+ patch_pyvenv_cfg(&python);
+ let Some(site) = torch_site_packages(&python, state) else {
+ return "ok";
+ };
+ // An interrupted CUDA swap is put back by ensure_torch_device first; until
+ // then what is installed is not the bundle's to judge.
+ if torchswap::has_backup(&site) {
+ return "ok";
+ }
+ let Some(torch) = installed_version(&site, "torch") else {
+ return "ok";
+ };
+ let vision = installed_version(&site, "torchvision");
+ // Only an absent version.py means torchvision is gone. One that is there
+ // but unreadable is not worth a download on every launch.
+ if vision.is_none() && site.join("torchvision").join("version.py").exists() {
+ return "ok";
+ }
+ let Some(spec) = torchvision_repair_spec(&torch, vision.as_deref()) else {
+ return "ok";
+ };
+
+ log_setup_step("torchvision-repair");
+ let found = match &vision {
+ Some(vision) => format!("torchvision {vision} is a GPU build"),
+ None => "torchvision is missing".to_string(),
+ };
+ append_to_setup_log(
+ &data_dir,
+ &format!("{found} beside torch {torch}; installing {spec}"),
+ );
+ let label = "torchvision repair";
+ // Puts a line on the setup screen before pip has said anything.
+ let _ = app.emit(
+ "setup-progress",
+ SetupProgress {
+ label: label.to_string(),
+ detail: "Repairing the karaoke split".to_string(),
+ received: None,
+ total: None,
+ },
+ );
+ // --force-reinstall, not --ignore-installed: pip uninstalls the CUDA wheel
+ // through its RECORD, which is what removes torchvision.libs/libcudart and
+ // libnvjpeg. pip downloads before it uninstalls, so offline this fails
+ // with the old wheel still in place.
+ let result = run_pip_install(
+ &python,
+ &[
+ &spec,
+ "--index-url",
+ "https://download.pytorch.org/whl/cpu",
+ "--force-reinstall",
+ "--no-deps",
+ ],
+ state,
+ app,
+ label,
+ )
+ .and_then(|()| verify_imports(&python, "torchvision", state));
+ match result {
+ Ok(()) => {
+ append_to_setup_log(&data_dir, &format!("torchvision repaired: {spec}"));
+ "repaired"
+ }
+ Err(e) => {
+ append_to_setup_log(
+ &data_dir,
+ &format!("torchvision repair failed, will retry next launch: {e}"),
+ );
+ "failed"
+ }
+ }
+}
+
#[cfg(not(target_os = "macos"))]
fn install_cuda_torch(
python: &Path,
@@ -3182,16 +4409,13 @@ fn install_cuda_torch(
// cu128 uses 2.8.0: 2.7.1 shipped incomplete sm_120 kernels for Blackwell
// (RTX 5000 series), causing verify_cuda_torch to fail (#239).
let tag = cuda_tag_from_url(index_url);
- let torch_version = torch_version_for_tag(tag);
- let torch_spec = format!("torch=={torch_version}+{tag}");
- let torchaudio_spec = format!("torchaudio=={torch_version}+{tag}");
- let torchvision_spec = format!("torchvision=={}+{tag}", torchvision_version_for_tag(tag));
+ let [torch_spec, torchaudio_spec, torchvision_spec] = cuda_torch_specs(tag);
for (label, args) in cuda_install_passes(
&torch_spec,
&torchaudio_spec,
&torchvision_spec,
index_url,
- cuda_wheel_needs_runtime_deps(),
+ cuda_wheel_needs_runtime_deps(tag),
) {
run_pip_install(python, &args, state, app, label)?;
}
@@ -3271,11 +4495,13 @@ enum CudaVerify {
}
/// Whether a failed verification was the "this build has no kernels for your
-/// GPU" error. Matched on CUDA's exact wording, because a looser match would
-/// settle a driver or install problem as permanent.
+/// GPU" error. Matched on CUDA's and HIP's exact wording, because a looser
+/// match would settle a driver or install problem as permanent.
#[cfg(any(not(target_os = "macos"), test))]
fn is_no_kernel_image(stderr: &str) -> bool {
stderr.contains("no kernel image is available for execution on the device")
+ || stderr.contains("hipErrorNoBinaryForGpu")
+ || stderr.contains("HIP error: invalid device function")
}
/// The reason recorded when CUDA setup does not end on the GPU.
@@ -3300,7 +4526,7 @@ fn cuda_failure_stage(outcomes: &[Option]) -> &'static str {
}
#[cfg(not(target_os = "macos"))]
-fn verify_cuda_torch(python: &Path) -> CudaVerify {
+fn verify_cuda_torch(python: &Path, state: &BackendState) -> CudaVerify {
// Don't trust torch.cuda.is_available() alone: it returns True even when the
// installed wheel has no kernels for the device (e.g. sm_120 on a cu124
// build), which then crashes mid-extraction with "no kernel image is
@@ -3325,7 +4551,9 @@ fn verify_cuda_torch(python: &Path) -> CudaVerify {
])
.stdout(Stdio::null())
.stderr(Stdio::piped());
- hide_console_window(&mut command);
+ // Verified on the GPU the backend will use, not whichever HIP numbers 0.
+ #[cfg(target_os = "linux")]
+ show_rocm_the_chosen_gpu(&mut command);
let log = |msg: &str| {
if let Ok(data_dir) = local_data_dir() {
@@ -3333,7 +4561,7 @@ fn verify_cuda_torch(python: &Path) -> CudaVerify {
}
};
- match command_output_with_timeout(command, GPU_VERIFY_TIMEOUT, "CUDA verify") {
+ match setup_probe_output(command, state, GPU_VERIFY_TIMEOUT, "CUDA verify") {
Ok(out) if out.status.success() => CudaVerify::Verified,
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
@@ -3416,7 +4644,8 @@ fn open_url(url: String) -> Result<(), String> {
// Use explorer.exe directly to avoid cmd.exe interpreting '&' in query strings.
let mut cmd = Command::new("explorer.exe");
cmd.arg(&url);
- cmd.spawn()
+ with_user_env(&mut cmd)
+ .spawn()
.map_err(|e| format!("failed to open URL: {e}"))?;
}
#[cfg(target_os = "macos")]
@@ -3436,19 +4665,17 @@ fn open_url(url: String) -> Result<(), String> {
Ok(())
}
-/// Only localhost URLs, and only http(s). Guards against a compromised WebView
-/// using the desktop shell as an SSRF proxy (#138).
-/// Hosts an in-app update may be fetched from.
-///
-/// GitHub serves release assets from `github.com` and redirects to
-/// `objects.githubusercontent.com`, so both have to be here.
-const RELEASE_ASSET_HOSTS: [&str; 2] = ["github.com", "objects.githubusercontent.com"];
+/// Where this repository's release assets live. The frontend passes the
+/// releases API's `browser_download_url`, which always has this shape; the
+/// redirect GitHub answers it with is followed by reqwest and never comes back
+/// through this check.
+const RELEASE_ASSET_PREFIX: &str = "/stemdeckapp/stemdeck/releases/download/";
/// Reject an update URL that does not point at our own release assets.
///
/// `download_app_update` takes its URL from the WebView, and the SHA-256 it
/// checks against comes from the same place -- so the checksum proves the file
-/// arrived intact, not that it came from us. Without a host check, anything
+/// arrived intact, not that it came from us. Without this check, anything
/// able to run script on that page can hand the shell an archive that
/// `apply_app_update` then extracts over StemDeck's own executable and
/// backend/ (#510). The page is served over http by the Python backend, which
@@ -3464,12 +4691,17 @@ fn validate_release_url(url: &str) -> Result<(), String> {
return Err("update URLs must use https".to_string());
}
let host = parsed.host_str().unwrap_or("");
- if !RELEASE_ASSET_HOSTS.contains(&host) {
- return Err(format!("refusing to download an update from {host}"));
+ if host != "github.com" || !parsed.path().starts_with(RELEASE_ASSET_PREFIX) {
+ return Err(format!(
+ "refusing to download an update from {host}{}",
+ parsed.path()
+ ));
}
Ok(())
}
+/// Only localhost URLs, and only http(s). Guards against a compromised WebView
+/// using the desktop shell as an SSRF proxy (#138).
fn validate_download_url(url: &str) -> Result<(), String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("only http/https URLs are permitted".to_string());
@@ -3559,32 +4791,114 @@ async fn download_to_path(
) -> Result<(), String> {
validate_download_url(&url)?;
let dest = take_pending_save(&state, &token)?;
- stream_url_to_file(&url, &dest).await
+ stream_url_to_file(&url, &dest).await?;
+ record_saved(&state, &token, dest)
}
-/// Streams a URL to `dest`, via a temp file so a failure never leaves a
-/// half-written export looking complete.
-///
-/// Streamed rather than buffered to avoid holding a large audio file in memory
-/// (#139); the 5-minute timeout covers large WAV exports over a slow loopback.
-/// The caller has already validated the URL.
-async fn stream_url_to_file(url: &str, dest: &Path) -> Result<(), String> {
- let client = reqwest::Client::builder()
- .timeout(Duration::from_secs(300))
- .build()
- .map_err(|e| format!("failed to build client: {e}"))?;
- let mut resp = client
- .get(url)
- .send()
- .await
- .map_err(|e| format!("fetch failed: {e}"))?;
- if !resp.status().is_success() {
- return Err(format!("backend returned HTTP {}", resp.status()));
- }
- let tmp = dest.with_extension("audio.download");
- let mut file =
- std::fs::File::create(&tmp).map_err(|e| format!("failed to create temp file: {e}"))?;
- while let Some(chunk) = resp
+/// Remembers the file the save `token` just wrote, for reveal_saved. At most
+/// MAX_PENDING_SAVES are kept (the oldest token goes first).
+fn record_saved(state: &BackendState, token: &str, dest: PathBuf) -> Result<(), String> {
+ let mut guard = state
+ .inner
+ .lock()
+ .map_err(|_| "state poisoned".to_string())?;
+ if guard.saved_files.len() >= MAX_PENDING_SAVES && !guard.saved_files.contains_key(token) {
+ if let Some(oldest) = guard
+ .saved_files
+ .keys()
+ .min_by_key(|k| k.parse::().unwrap_or(u64::MAX))
+ .cloned()
+ {
+ guard.saved_files.remove(&oldest);
+ }
+ }
+ guard.saved_files.insert(token.to_string(), dest);
+ Ok(())
+}
+
+/// The file the save `token` wrote, if it is still there.
+fn saved_file(state: &BackendState, token: &str) -> Result {
+ let path = state
+ .inner
+ .lock()
+ .map_err(|_| "state poisoned".to_string())?
+ .saved_files
+ .get(token)
+ .cloned()
+ .ok_or_else(|| "nothing was saved by this export".to_string())?;
+ if !path.is_file() {
+ return Err("the saved file is no longer there".to_string());
+ }
+ Ok(path)
+}
+
+/// The program and arguments that show `path` in the system's file manager:
+/// selected in Explorer and Finder, its folder opened on Linux (xdg-open has
+/// no "select"). Arguments, never a shell string, so no part of the path is
+/// ever interpreted.
+fn reveal_command(path: &Path) -> (&'static str, Vec) {
+ #[cfg(target_os = "windows")]
+ {
+ // Explorer takes "/select," and the path as the next argument.
+ let args = vec![
+ std::ffi::OsString::from("/select,"),
+ path.as_os_str().to_owned(),
+ ];
+ ("explorer", args)
+ }
+ #[cfg(target_os = "macos")]
+ {
+ let args = vec![std::ffi::OsString::from("-R"), path.as_os_str().to_owned()];
+ ("open", args)
+ }
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
+ {
+ let dir = path.parent().unwrap_or(path);
+ ("xdg-open", vec![dir.as_os_str().to_owned()])
+ }
+}
+
+/// Shows the file an export saved in the system's file manager (Streamer
+/// Mode's "Show in folder"). Takes the save's token, never a path: only a file
+/// this app wrote for that caller's own save can be revealed. Async, so the
+/// command never holds the main thread; the file manager's process is waited
+/// on in a thread of its own so it never lingers as a zombie.
+#[tauri::command]
+async fn reveal_saved(state: tauri::State<'_, BackendState>, token: String) -> Result<(), String> {
+ let path = saved_file(&state, &token)?;
+ let (program, args) = reveal_command(&path);
+ let mut child = with_user_env(Command::new(program).args(&args))
+ .spawn()
+ .map_err(|e| format!("could not open the file manager: {e}"))?;
+ std::thread::spawn(move || {
+ let _ = child.wait();
+ });
+ Ok(())
+}
+
+/// Streams a URL to `dest`, via a temp file so a failure never leaves a
+/// half-written export looking complete.
+///
+/// Streamed rather than buffered to avoid holding a large audio file in memory
+/// (#139); the 5-minute timeout covers large WAV exports over a slow loopback.
+/// The caller has already validated the URL.
+async fn stream_url_to_file(url: &str, dest: &Path) -> Result<(), String> {
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(300))
+ .build()
+ .map_err(|e| format!("failed to build client: {e}"))?;
+ let mut resp = client
+ .get(url)
+ .send()
+ .await
+ .map_err(|e| format!("fetch failed: {e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("backend returned HTTP {}", resp.status()));
+ }
+ let tmp = dest.with_extension("audio.download");
+ let mut file =
+ std::fs::File::create(&tmp).map_err(|e| format!("failed to create temp file: {e}"))?;
+ while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| format!("read failed: {e}"))?
@@ -3661,21 +4975,33 @@ async fn start_audio_drag(
.map_err(|e| e.to_string())
}
-fn stop_backend(state: &BackendState) {
- let (handles, _setup_child_pid) = match state.inner.lock() {
- Ok(mut guard) => (guard.handles.take(), guard.setup_child_pid.take()),
- Err(_) => return,
+/// Stops everything for the window's close, and says whether StemDeck may
+/// exit now: not while an update's renames run (UpdateSwap), which then
+/// relaunches and exits by itself, or the close can be asked for again.
+fn stop_backend(state: &BackendState) -> bool {
+ let (handles, setup_child) = match state.inner.lock() {
+ Ok(mut guard) => {
+ if guard.swapping {
+ return false;
+ }
+ guard.closing = true;
+ (guard.handles.take(), guard.setup_child.take())
+ }
+ Err(_) => return true,
};
- // Kill any in-progress setup-time subprocess (pip install, model warmup)
- // so it doesn't corrupt the venv/cache if the window is closed mid-setup (#140).
- #[cfg(unix)]
- if let Some(pid) = _setup_child_pid {
- // SAFETY: pid was stored immediately after spawn; we send SIGTERM best-effort.
- unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
+ // Stop any in-progress setup-time subprocess (pip install, model warmup)
+ // so it doesn't corrupt the venv/cache if the window is closed mid-setup
+ // (#140), on Windows too (#405).
+ if let Some(child) = setup_child {
+ if let Err(e) = child.stop() {
+ eprintln!("[stemdeck] {e}");
+ }
}
- let Some(mut handles) = handles else { return };
+ let Some(mut handles) = handles else {
+ return true;
+ };
// Drain the backend on a background thread so we don't block the Tauri
// RunEvent main thread for up to 3 seconds (#144).
@@ -3695,9 +5021,13 @@ fn stop_backend(state: &BackendState) {
thread::sleep(Duration::from_millis(100));
}
}
+ // Windows has no graceful stop: the kill is hard, and the backend's
+ // own shutdown (which stops video renders) does not run. Dropping
+ // `handles` closes its job, which ends what it started (#790).
let _ = handles.child.kill();
let _ = handles.child.wait();
});
+ true
}
/// Returns the persistent user data directory for StemDeck.
@@ -3766,6 +5096,17 @@ fn log_setup_step(step: &str) {
}
}
+// The setup.log line for a failed cleanup, or None when it worked or there
+// was nothing to remove.
+fn cleanup_warning(path: &Path, result: std::io::Result<()>) -> Option {
+ match result {
+ Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
+ Some(format!("cleanup warning: {}: {e}", path.display()))
+ }
+ _ => None,
+ }
+}
+
fn append_to_setup_log(data_dir: &Path, msg: &str) {
let log = data_dir.join("logs").join("setup.log");
if let Some(p) = log.parent() {
@@ -4135,12 +5476,17 @@ fn download_file(url: &str, target: &Path, timeout: Duration, label: &str) -> Re
const RETRY_BACKOFF: [Duration; 2] = [Duration::from_secs(2), Duration::from_secs(5)];
let mut last_detail = String::new();
+ let mut last_code = None;
for attempt in 0..MAX_ATTEMPTS {
let mut command = Command::new("curl");
command
.args([
"--fail",
"--location",
+ // --silent keeps the progress meter out of stderr, which is
+ // all the error below quotes. Without it a failed download
+ // showed the user a screen of transfer statistics (#824).
+ "--silent",
"--show-error",
"--connect-timeout",
CONNECT_TIMEOUT_SECS,
@@ -4158,23 +5504,58 @@ fn download_file(url: &str, target: &Path, timeout: Duration, label: &str) -> Re
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ last_code = output.status.code();
last_detail = if stderr.is_empty() {
- format!("curl exited with status {:?}", output.status.code())
+ format!("curl exited with status {last_code:?}")
} else {
stderr
};
- if !curl_exit_is_retriable(output.status.code()) || attempt + 1 == MAX_ATTEMPTS {
+ if !curl_exit_is_retriable(last_code) || attempt + 1 == MAX_ATTEMPTS {
break;
}
std::thread::sleep(RETRY_BACKOFF[attempt as usize]);
}
- Err(format!(
- "Could not reach the download server for {label}. Check your internet \
- connection and try again. ({last_detail})"
+ Err(describe_download_failure(
+ label,
+ url,
+ last_code,
+ &last_detail,
))
}
+// The HTTP status curl's --fail reported, from "The requested URL returned
+// error: 429".
+#[cfg(any(unix, test))]
+fn curl_http_status(detail: &str) -> Option {
+ let (_, rest) = detail.split_once("returned error: ")?;
+ let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+ digits.parse().ok()
+}
+
+// Why a download failed, in words that say which side it was on. A server
+// that answered with an HTTP error was reached: telling that user to check
+// their connection or firewall sent them looking in the wrong place, and the
+// URL was never named (#824). curl exit 22 is --fail's HTTP error. A 429 is
+// not retried here (curl_exit_is_retriable), so a busy host is not hammered.
+#[cfg(any(unix, test))]
+fn describe_download_failure(label: &str, url: &str, code: Option