diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index 8974daf..d819e13 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -3,16 +3,20 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
+# or dependency change: a drifted dependency still shows up within a day, and always before a
+# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
+ schedule:
+ - cron: "47 3 * * *"
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- "crates/playback/**"
- .github/workflows/freebsd.yml
+ - packaging/freebsd/**
+ - packaging/env.sh
workflow_dispatch:
concurrency:
@@ -24,27 +28,5 @@ permissions:
jobs:
freebsd:
- name: FreeBSD 14 (build + test)
- runs-on: ubuntu-latest
- timeout-minutes: 300
- steps:
- - uses: actions/checkout@v4
- - name: Build and test in a FreeBSD VM
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
- with:
- release: "14.3"
- usesh: true
- cpu: 4
- mem: 12288
- # Nothing to bring back; copying the target dir out of the VM fails on large builds.
- copyback: false
- prepare: |
- pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
- curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
- run: |
- . "$HOME/.cargo/env"
- # The checkout is synced in under another owner; let tests that call git read it.
- git config --global --add safe.directory "*"
- rustc --version
- CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
- CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
\ No newline at end of file
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index ce126e3..f54b053 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -19,49 +19,5 @@ concurrency:
jobs:
lint:
- runs-on: ubuntu-latest
- env:
- ACTIONLINT_VERSION: 1.7.12
- steps:
- - uses: actions/checkout@v4
- - name: Install tools
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
- curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
- | tar -xz -C "$RUNNER_TEMP" actionlint
- - name: actionlint
- run: |
- "$RUNNER_TEMP/actionlint" -color
- - name: shellcheck
- run: shellcheck packaging/*.sh packaging/*/*.sh
- - name: PowerShell
- shell: pwsh
- run: |
- foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
- $errors = $null
- [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
- if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
- Write-Output "ok $($f.Name)"
- }
- - name: XML, desktop entry, AppStream, Flatpak manifest
- run: |
- xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
- # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
- if grep -oE '"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
- appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml"
- python3 - <<'EOF'
- import yaml
- src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml"))
- bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"))
- for m in (src, bundle):
- assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"]
- assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio"
- for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"):
- assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
- print("Flatpak manifests ok")
- EOF
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b1c0a39..d60e820 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,11 +1,3 @@
-# Release pipeline: every push to `release` builds signed installers for every platform and
-# creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md.
-#
-# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release`
-# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
-# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
-# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them
-# (a dry run); the signed jobs are refused there, and so the draft release is skipped.
name: Release
on:
@@ -19,359 +11,32 @@ on:
default: ""
permissions:
- contents: read
+ contents: write
concurrency:
group: release-${{ github.ref }}
- # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
-env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: 0
- RUST_BACKTRACE: 1
- SOUNDCRAFT_BUILD_SHA: ${{ github.sha }}
- INPUT_VERSION: ${{ inputs.version }}
-
jobs:
- version:
- name: version
- runs-on: ubuntu-latest
- # No `environment: release`: this job reads no secrets (and gates every other job).
- outputs:
- version: ${{ steps.v.outputs.version }}
- date: ${{ steps.v.outputs.date }}
- prerelease: ${{ steps.v.outputs.prerelease }}
- steps:
- - uses: actions/checkout@v4
- - id: v
- name: Resolve version
- run: |
- . packaging/env.sh
- v="${INPUT_VERSION#v}"
- v="${v:-$VERSION}"
- if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
- echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
- exit 1
- fi
- case "$v" in *-*) pre=true ;; *) pre=false ;; esac
- {
- echo "version=$v"
- echo "date=$(date -u +%Y-%m-%d)"
- echo "prerelease=$pre"
- } >>"$GITHUB_OUTPUT"
- echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
-
- macos:
- name: macOS universal
- needs: version
- runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
- environment: release
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- with:
- targets: aarch64-apple-darwin, x86_64-apple-darwin
- - uses: Swatinem/rust-cache@v2
- with:
- key: release-macos-universal
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- - name: Import signing certificate
- env:
- APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- run: packaging/macos/import-cert.sh
- - name: Build, sign, notarize
- env:
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
- APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- run: packaging/macos/package.sh --arch universal
- - uses: actions/upload-artifact@v4
- with:
- name: release-macos
- path: dist/release/*
- if-no-files-found: error
- - name: Remove temporary keychain
- if: always()
- run: |
- if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
-
- windows:
- name: Windows ${{ matrix.arch }}
- needs: version
- runs-on: windows-latest
- environment: release
- strategy:
- fail-fast: false
- matrix:
- include:
- - { arch: x64, target: x86_64-pc-windows-msvc }
- - { arch: x86, target: i686-pc-windows-msvc }
- # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
- # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- - { arch: arm64, target: aarch64-pc-windows-msvc }
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- with:
- targets: ${{ matrix.target }}
- - uses: Swatinem/rust-cache@v2
- with:
- key: release-windows-${{ matrix.arch }}
- - name: Apply version override
- if: inputs.version != ''
- shell: bash
- run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- - name: Install WiX
- shell: pwsh
- run: dotnet tool install --global wix --version 5.0.2
- - name: Build, sign, package
- shell: pwsh
- env:
- WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
- WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
- AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
- AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
- AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
- AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
- AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
- run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- - uses: actions/upload-artifact@v4
- with:
- name: release-windows-${{ matrix.arch }}
- path: dist/release/*
- if-no-files-found: error
-
- linux:
- name: Linux ${{ matrix.arch }}
- needs: version
- # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
- # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
- runs-on: ${{ matrix.runner }}
- # No `environment: release`: this job signs nothing, so it gets no release secrets.
- strategy:
- fail-fast: false
- matrix:
- include:
- - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- NFPM_VERSION: 2.47.0
- steps:
- - uses: actions/checkout@v4
- - name: Install build and packaging tools
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
- libasound2-dev pkg-config \
- desktop-file-utils appstream python3-yaml zsync
- curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
- "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
- sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- - uses: dtolnay/rust-toolchain@stable
- - uses: Swatinem/rust-cache@v2
- with:
- key: release-linux-${{ matrix.arch }}
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- - name: Build and package (AppImage, deb, rpm, tar.gz)
- run: packaging/linux/package.sh
- - name: Check the packages
- run: |
- dpkg-deb --info dist/release/*.deb
- dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications'
- ldd target/release/soundcraft
- # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml).
- ldd target/release/soundcraft | grep -q libasound
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'"
- for appimage in dist/release/*.AppImage; do
- chmod +x "$appimage"
- APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
- # Update information is embedded, and the .zsync for delta updates sits beside it.
- "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync'
- test -s "$appimage.zsync"
- done
- - uses: actions/upload-artifact@v4
- with:
- name: release-linux-${{ matrix.arch }}
- path: dist/release/*
- if-no-files-found: error
-
- flatpak:
- name: Flatpak ${{ matrix.arch }}
- needs: [version, linux]
- # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
- # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
- runs-on: ${{ matrix.runner }}
- # No `environment: release`: this job signs nothing, so it gets no release secrets.
- strategy:
- fail-fast: false
- matrix:
- include:
- - { arch: x86_64, runner: ubuntu-24.04 }
- - { arch: aarch64, runner: ubuntu-24.04-arm }
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/download-artifact@v4
- with:
- name: release-linux-${{ matrix.arch }}
- path: dist/linux
- - name: Install flatpak-builder
- run: |
- sudo apt-get update
- # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
- # `appstreamcli compose`, which renders the scalable icon and fails without it.
- sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
- # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
- sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- - name: Build the Flatpak bundle
- run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- - uses: actions/upload-artifact@v4
- with:
- name: release-flatpak-${{ matrix.arch }}
- path: dist/release/*.flatpak
- if-no-files-found: error
-
- freebsd:
- name: FreeBSD x86_64
- needs: version
- # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
- runs-on: ubuntu-latest
- timeout-minutes: 150
- # No `environment: release`: this job signs nothing, so it gets no release secrets.
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- if: inputs.version != ''
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- - name: Build and package in a FreeBSD VM
- # v1.5.9, pinned by commit.
- uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
- with:
- release: "14.3"
- usesh: true
- mem: 12288
- # Passed through by name; never templated into the script.
- envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA"
- # alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime).
- prepare: |
- pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
- run: |
- . "$HOME/.cargo/env"
- git config --global --add safe.directory "$PWD"
- # Build outside the checkout so only dist/ is copied back out of the VM.
- export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4
- bash packaging/freebsd/package.sh
- - uses: actions/upload-artifact@v4
- with:
- name: release-freebsd
- path: dist/release/*
- if-no-files-found: error
-
- web:
- name: Web (wasm)
- needs: version
- runs-on: ubuntu-latest
- # No `environment: release`: this job signs nothing, so it gets no release secrets.
- env:
- SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }}
- SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
- TRUNK_VERSION: 0.21.14
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- with:
- targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@v2
- with:
- key: release-web
- - name: Install trunk
- run: |
- curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
- | tar -xz -C "$HOME/.cargo/bin" trunk
- trunk --version
- - name: Apply version override
- if: inputs.version != ''
- run: cargo xtask version set "$SOUNDCRAFT_VERSION"
- - name: Build and zip
- run: packaging/web/package.sh
- - uses: actions/upload-artifact@v4
- with:
- name: release-web
- path: dist/release/*
- if-no-files-found: error
-
release:
- name: Draft GitHub Release
- # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts.
- needs: [version, macos, windows, linux, flatpak, freebsd, web]
- runs-on: ubuntu-latest
- environment: release
- permissions:
- contents: write
- env:
- VERSION: ${{ needs.version.outputs.version }}
- PRERELEASE: ${{ needs.version.outputs.prerelease }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- steps:
- - uses: actions/download-artifact@v4
- with:
- pattern: release-*
- path: artifacts
- merge-multiple: true
- - name: Checksums
- working-directory: artifacts
- run: |
- sha256sum -- * | tee SHA256SUMS.txt
- - name: Create or update the draft release
- working-directory: artifacts
- run: |
- tag="v$VERSION"
- title="SoundCraft v$VERSION"
- pre=()
- if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
- if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
- if [ "$draft" != "true" ]; then
- echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
- exit 1
- fi
- echo "Updating draft $tag: replacing its assets"
- gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
- gh release delete-asset "$tag" "$asset" --yes
- done
- gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
- gh release upload "$tag" --clobber -- *
- else
- echo "Creating draft $tag"
- gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
- fi
- {
- echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
- echo
- sed 's/^/ /' SHA256SUMS.txt
- } >>"$GITHUB_STEP_SUMMARY"
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/release.yml@main
+ with:
+ app-name: soundcraft
+ app-title: SoundCraft
+ version: ${{ inputs.version }}
+ secrets:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
+ AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
+ AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index bafdca6..d389f4d 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
+# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
+# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
+ schedule:
+ - cron: "53 5 * * *"
push:
- branches: [main]
+ branches: [release]
pull_request:
paths:
- - Cargo.toml
- - Cargo.lock
- - "**/Cargo.toml"
- - "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
- schedule:
- - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
@@ -26,70 +24,10 @@ concurrency:
permissions:
contents: read
-env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: 0
-
jobs:
- package:
- name: package (cross-compiled on x64)
- runs-on: windows-latest
- timeout-minutes: 90
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
- with:
- targets: aarch64-pc-windows-msvc
- - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- with:
- key: windows-arm64-package
- - name: Install WiX
- shell: pwsh
- run: dotnet tool install --global wix --version 5.0.2
- - name: Build and package (unsigned)
- shell: pwsh
- env:
- DIST: ${{ github.workspace }}\dist\arm64
- run: ./packaging/windows/package.ps1 -Arch arm64
- - uses: actions/upload-artifact@v4
- with:
- name: soundcraft-windows-arm64
- path: dist/arm64/*
- if-no-files-found: error
- retention-days: 7
-
- install:
- name: install and run on ARM64
- needs: package
- runs-on: windows-11-arm
- timeout-minutes: 30
- steps:
- - uses: actions/download-artifact@v4
- with:
- name: soundcraft-windows-arm64
- path: dist
- - name: Install the MSI, check it, run it, uninstall it
- shell: pwsh
- run: |
- $ErrorActionPreference = 'Stop'
- $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
- $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
- if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
- $dir = Join-Path $env:ProgramFiles 'SoundCraft'
- foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') {
- $path = Join-Path $dir $exe
- if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
- $bytes = [System.IO.File]::ReadAllBytes($path)
- $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
- if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
- Write-Output "ok $path (ARM64)"
- }
- # The installed CLI runs natively on ARM64.
- $cli = Join-Path $dir 'soundcraft-cli.exe'
- $out = & $cli --version 2>&1 | Out-String
- if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" }
- Write-Output "ok soundcraft-cli --version: $($out.Trim())"
- $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
- if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
- if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' }
- Write-Output 'ok uninstalled'
+ windows-arm64:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
+ with:
+ app-name: soundcraft
+ app-dir: SoundCraft
diff --git a/AGENTS.md b/AGENTS.md
index 629b9fa..4fdf3cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,7 +6,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
-2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate.
+2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
## ⚠️ Assets: the absolute rule
@@ -51,7 +51,7 @@ Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read e
## Everything is a command
User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`.
-Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises.
+Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
## Running and looking at the app
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
@@ -66,10 +66,10 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
## Release
-Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
+Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
## Roadmap
-`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands.
+`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
## Contributor credits (About window)
diff --git a/Cargo.lock b/Cargo.lock
index 0642d45..480e113 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -782,6 +782,15 @@ dependencies = [
"windows 0.54.0",
]
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+dependencies = [
+ "egui",
+ "serde",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -837,23 +846,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
-[[package]]
-name = "dark-light"
-version = "3.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745"
-dependencies = [
- "futures-channel",
- "futures-core",
- "objc2 0.6.5",
- "objc2-foundation 0.3.2",
- "wasm-bindgen",
- "web-sys",
- "windows-sys 0.61.2",
- "winreg",
- "zbus",
-]
-
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -1319,15 +1311,6 @@ dependencies = [
"percent-encoding",
]
-[[package]]
-name = "futures-channel"
-version = "0.3.34"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
-dependencies = [
- "futures-core",
-]
-
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -3429,12 +3412,14 @@ dependencies = [
[[package]]
name = "soundcraft"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"eframe",
"egui",
"log",
"muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-engine",
@@ -3445,7 +3430,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3455,7 +3440,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3467,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"serde_json",
@@ -3477,7 +3462,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3489,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3500,7 +3485,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3511,7 +3496,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3532,7 +3517,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3542,7 +3527,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"log",
"rayon",
@@ -3558,7 +3543,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3571,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"cpal",
"log",
@@ -3584,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3594,9 +3579,9 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
- "dark-light",
+ "craft-ui",
"egui",
"egui_kittest",
"image",
@@ -3620,7 +3605,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"image",
"proptest",
@@ -3631,7 +3616,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"libloading",
"log",
@@ -3646,7 +3631,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -5106,16 +5091,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "winreg"
-version = "0.56.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
-dependencies = [
- "cfg-if",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "winresource"
version = "0.1.31"
@@ -5203,7 +5178,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.3.0"
+version = "0.5.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5e69697..4eaad85 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.3.0"
+version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -41,10 +41,10 @@ rustfft = "6"
cpal = "0.16"
# ui
+craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
-dark-light = "3"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
diff --git a/README.md b/README.md
index 89ec40e..b297d75 100644
--- a/README.md
+++ b/README.md
@@ -137,7 +137,8 @@ where things are.
## Getting started
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
-from source with a recent stable Rust:
+from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
+minimum remains Rust 1.90):
```sh
git clone https://github.com/storytold/soundcraft
@@ -236,10 +237,15 @@ and layering checks and the WebAssembly build.
## Status and roadmap
-SoundCraft is **pre-alpha, and close to its first alpha**: it covers 94 % of the incumbent's menu
-items and roughly two thirds of its features in depth. The core works and is fun to use, and plenty is still missing. The
-honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the
-menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are
+SoundCraft is **pre-alpha**: it covers 94 % of the incumbent's menu items (measured), about 70 % of
+its features by presence, and is roughly 40 % of the way to replacing it for real work: record,
+edit, mix and bounce work end to end, but choosing an audio interface and buffer size,
+recording MIDI from a controller and session interchange (AAF/OMF) are still missing, and those
+are the alpha checklist. Translations are missing too. The status, the stage and our effort
+estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
+[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
+[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
+[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
very welcome, in the issues or on Discord.
## Downloads
@@ -271,6 +277,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
+DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
+`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
+(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
+
### macOS
| Build | File | Notes |
@@ -288,6 +298,8 @@ and web backend defaults are unchanged.
| Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | |
| Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+RISC-V (riscv64): `soundcraft--linux-riscv64.tar.gz`, a tarball only (glibc 2.39+, e.g. Ubuntu 24.04).
+
### FreeBSD
| Build | File |
@@ -366,3 +378,5 @@ Forks and modified versions must remove them.
Made by the ArtCraft team and community.
+
+Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
diff --git a/ROADMAP.md b/ROADMAP.md
index a89da3f..25ae055 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,54 +1,114 @@
# SoundCraft roadmap
+**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
-Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This file is
-the honest status: what works today, what is missing, and how far we are.
+Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
+the one-page summary; the detail is in [`docs/target-app-parity.md`](docs/target-app-parity.md)
+(assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
+(milestones and beta plan) and [`docs/parity-checklist.md`](docs/parity-checklist.md) (generated
+menu checklist).
-## Where we are (2026-10-07)
+## Headline numbers
-| Measure | Value | How |
+| Number | Value | Kind |
|---|---|---|
-| Menu-catalog parity (engine + UI) | **482 / 512 menu items (94 %)**; engine alone 397 / 512, see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry. Most of what is left is Avid's online services (sign-in, cloud projects, collaboration, Splice, Learn), Dolby Atmos-specific items, HEAT and ARA |
-| Estimated overall feature parity | **~65 %** | judgement across the areas below, weighted by how much working engineers rely on them. Menu items are only the surface; depth (editing feel, plugin catalogue, MIDI and score editing, video) is where the gap is |
-| Distance to an **alpha** | **~85 % there, ~12–18 wall-clock hours** of Claude Opus 5.5 work | see [Alpha](#alpha) below |
-| Distance to **100 % parity** | **~110–150 wall-clock hours** of Claude Opus 5.5 work with a single agent; roughly 40–55 hours with 3–4 agents in parallel (the last three features in this session were built in parallel this way) | sum of the per-area estimates below |
+| Menu breadth | **482 / 512 Pro Tools menu leaves (94.1 %)**; engine alone 397 / 512 | measured (`cargo xtask parity` + UI-layer aliases) |
+| Feature breadth | **~70 %** | estimated (menus plus plugins, hardware, formats, immersive, cloud) |
+| **Ready for real work** | **~40 %** | estimated, weighted by dimension |
+| Mainstream practitioner (tracking/mixing engineer) | **~36 %** | estimated: 50.6 % weekly-area depth × 0.90 interaction × 0.92 stability × 0.85 file exchange ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
+| Essentials user (song or podcast, defaults) | **~57 %** | estimated: 72.4 % core-feature depth × 0.90 launch/stability × 0.90 discoverability × 0.97 files ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
+| Remaining to alpha | **~110–180 Opus 5.5 agent-hours** (the failing gate rows) | estimated, calibrated on PR history |
+| Remaining to beta | **~350–570 Opus 5.5 agent-hours** (includes the alpha work; ~60–70 % parallelizable) | estimated |
+| Remaining to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated |
-### Area by area
+| Audience | Ready % | Agent-hours to ~95 % | Work that dominates |
+|---|---:|---:|---|
+| Full target (ready for real work) | ~40 % | ~950–1,450 h | Hardware, the stock plugin catalogue, AAF/OMF and immersive, localization, fidelity |
+| Mainstream practitioner | ~36 % | ~380–600 h | Interface and MIDI recording, AAF/OMF, stock-plugin depth, core-path bugs |
+| Essentials user | ~57 % | ~110–200 h | Launch and platform fixes, onboarding, effect panels and presets, MP3/AAC export |
-| Area | Status | Parity | Remaining (h) |
-|---|---|---:|---:|
-| Session model, save/load, undo | Tracks, playlists, clips, fades, clip gain, automation, markers, groups, busses, video; JSON `.scraft` + Audio Files folder; plugin state; autosave; copy-on-write undo with gesture coalescing | 85 % | 8 |
-| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks. Whole files are decoded into memory: disk streaming for very long sessions is still to do | 70 % | 12 |
-| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 |
-| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects, preamp/instrument/object views | 75 % | 15 |
-| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 |
-| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 |
-| Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 |
-| MIDI & notation | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll, velocity, rubber-band select, keyboard transpose/move), event list, step input, quantize/transpose/velocity/duration, real-time properties, Score Editor, MusicXML export (Sibelius) and printable SVG score | 55 % | 20 |
-| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 |
-| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
-| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
-| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
-| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
-| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
-| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |
+Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's PRs; 60–70 % parallelizes.
+Detail: [target-app-parity.md](docs/target-app-parity.md#readiness-by-audience).
-## Alpha
+**Why pre-alpha:** the core-workflow gate in [docs/roadmap.md](docs/roadmap.md#alpha-gate) fails.
+Editing, mixing, bouncing, and saving and reopening work end to end. But a Pro Tools user can't
+record from a chosen audio interface at a chosen buffer size (only the default devices, a fixed
+512-sample block, and takes held in RAM). They can't record MIDI from a keyboard (there is no
+MIDI device layer). And they can't bring a session in or hand one back (no AAF/OMF/`.ptx`).
+Readiness (~40 %) sits right at the alpha bar, but a gate failure keeps the app pre-alpha. The
+2026-10-07 estimate ("~65 % overall, 110–150 h to 100 %") counted feature presence. This one
+measures readiness and adds hardware, localization and field bug reports.
-An alpha is a build people can download, open the demo or their own audio in, record, edit, mix
-and bounce without crashes or lost work. Feature-wise SoundCraft is past that bar; what is left is
-shipping and hardening:
+## By dimension
-1. **Publish the repository and cut the first release** (needs a decision from the maintainers:
- the GitHub repo, the release secrets, and a push to the `release` branch). The pipelines exist
- but have never run end to end. ~2 h once unblocked, mostly fixing what the first run finds.
-2. **Platform smoke tests** of the installers on Windows, Linux and FreeBSD (only macOS has been
- exercised by hand). ~3–4 h.
-3. **Disk streaming** of long sources, so an hour-long multitrack session does not need all of its
- audio in RAM. ~5–6 h.
-4. **A hands-on QA pass** in the live app: recording on real interfaces, long sessions, plugin-heavy
- mixes, save/reopen loops. ~3–4 h.
-5. **Docs**: a short user guide next to the existing agent/CLI docs. ~1 h.
+| Dimension | Ready | Remaining (h) | Doc |
+|---|---:|---:|---|
+| Features | ~45 % | 520–810 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
+| UI/UX fidelity | ~55 % | 60–100 | [ui-parity.md](docs/ui-parity.md) |
+| File formats | ~40 % | 70–120 | [file-format-parity.md](docs/file-format-parity.md) |
+| Hardware (interfaces, MIDI, surfaces, sync) | ~12 % | 110–180 | [hardware-parity.md](docs/hardware-parity.md) |
+| Localization | ~12 % (1 of Pro Tools' 8 languages) | 70–110 | [localization-parity.md](docs/localization-parity.md) |
+| Performance | ~35 % (unmeasured) | 40–70 | [hardware-parity.md](docs/hardware-parity.md#performance) |
+| Stability | ~45 % | 50–80 | [gaps.md](docs/gaps.md) |
+| Platforms | ~65 % (5 platforms built, macOS exercised) | 20–40 | [gaps.md](docs/gaps.md) |
+| Ecosystem / plugins | ~30 % | in Features + Hardware | [plugin-parity.md](docs/plugin-parity.md) |
+| AI features | ~5 % | 30–50 | [gaps.md](docs/gaps.md) |
+| Agent control | ahead of Pro Tools | 3 | [control-protocol.md](docs/control-protocol.md), [mcp.md](docs/mcp.md) |
+
+## Features
+
+| Area | Breadth | Ready | Remaining (h) |
+|---|---:|---:|---:|
+| Recording and monitoring | 75 % | 40 % | 40–60 |
+| Editing | 92 % | 65 % | 40–60 |
+| Mixing and routing | 85 % | 60 % | 35–50 |
+| Automation | 80 % | 55 % | 20–30 |
+| Built-in plugins and AudioSuite | 40 % | 35 % | 80–120 |
+| Third-party plugin hosting | 70 % | 45 % | 40–60 |
+| MIDI and instruments | 70 % | 30 % | 50–80 |
+| Score and notation | 60 % | 35 % | 20–30 |
+| Elastic Audio, TCE, Beat Detective | 70 % | 35 % | 40–60 |
+| Surround and immersive | 55 % | 35 % | 50–80 |
+| Video and post | 60 % | 40 % | 40–60 |
+| Session, file management, interchange | 55 % | 35 % | 60–100 |
+| Collaboration and cloud (Avid services) | 0 % | 0 % | owner decision |
+| Agent control | 100 % | 100 % | 3 |
+
+Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#feature-areas).
+
+## Languages
+
+| Language | Code | Status | Translated |
+|---|---|---|---:|
+| English | en | full | 100 % |
+| Simplified Chinese | zh-Hans | none | 0 % |
+| Spanish | es | none (PR #143 open) | 0 % |
+| Hindi | hi | none | 0 % |
+| Arabic | ar | none | 0 % |
+| French | fr | none (PR #143 open) | 0 % |
+| Portuguese | pt | none | 0 % |
+| Indonesian | id | none | 0 % |
+| Japanese | ja | none | 0 % |
+| German | de | none | 0 % |
+| Korean | ko | none | 0 % |
+| Vietnamese | vi | none | 0 % |
+
+Other languages shipped: 0. Detail: [localization-parity.md](docs/localization-parity.md).
+
+## Upcoming
+
+Ranked; the alpha gate, the alpha checklist and the beta plan are in [docs/roadmap.md](docs/roadmap.md).
+
+1. **Alpha blocker:** audio interfaces: device and buffer-size choice, duplex stream, ASIO; record to disk (G-01, G-06). 35–55 h.
+2. **Alpha blocker:** MIDI devices: record from keyboards, play instruments live, output (G-02). 20–35 h.
+3. **Alpha blocker:** AAF/OMF import and export (G-03). 50–90 h.
+4. macOS plugin entitlement (#153), then AU editors (G-05). 1 h for the entitlement (needed for the mix row), then 15–25 h.
+5. Land the open bug-fix PRs and clear the correctness backlog (G-04). 15–25 h.
+6. PR CI running `cargo xtask ci` on three OSes (G-14). 5–10 h.
+7. Fidelity harness against Pro Tools (G-07). 20–30 h.
## Robustness
@@ -57,22 +117,35 @@ command parameters through the engine and mixer; they have caught fade, overflow
unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo
sessions. Hosted plugins live in isolated unsafe crates (`clap-host`, `vst3-host`, `au-host`) and
are created, loaded and destroyed off the audio thread. The video decoders survive hundreds of
-mutated and truncated movies.
+mutated and truncated movies. Field reports from the first public week are tracked in
+[gaps.md](docs/gaps.md) (G-04).
-## Current focus
+## Progress log
-1. The alpha list above.
-2. Surround-pan automation; AU editor views.
-3. Built-in plugin depth (more processors, better UIs).
-4. MIDI and score editing depth.
+- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
+- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
+ progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
+ localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
+ tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
+- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
+ warnings, session audio health, malformed-JSON and encoder-extension checks in the CLI, macOS
+ system menu bar, Windows DX12 default, Linux fontconfig, metronome click in playback.
+- **2026-10-08** — v0.2.x–v0.3.0 released (Flatpak, AppImage zsync); VST3 quit crash fixed;
+ Ctrl-as-Cmd shortcuts on Windows/Linux; plugin menus by vendor, AU inserts, instrument picker;
+ rotating log file; branded DMG.
+- **2026-10-07** — Repository published; About window credits; release CI on macOS runners.
+- **2026-10-06** — Video track, Audio Unit hosting, plugin state and editors, Renderer; Mix
+ window views; score export and print; surround mixing; VST3 hosting; property tests.
+- **2026-10-05** — Foundation crates, Edit and Mix windows, playback, CLI/MCP, parity report,
+ recording and punch, CLAP hosting, Elastic Audio, Beat Detective, release pipeline.
-## Milestones
+## Revision history
-- **M0–M4 (done):** workspace, gates, model, IO, DSP, mix engine, command engine, Edit/Mix UI, realtime playback, metering, plugins, bounce.
-- **M5 (done):** editing depth (tools, modes, playlists, fades, comping, clip groups).
-- **M6 (done for the first cut):** recording, punch, loop record, monitoring.
-- **M7 (in progress):** MIDI and notation editing.
-- **M8 (done):** CLI, control channel, MCP, agent acceptance test.
-- **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective.
-- **M10 (next):** alpha release on all platforms.
-- **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround.
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
+| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
+| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
+| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
+| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
+| 2026-10-06 | minor | Alpha and parity estimates |
diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs
index 2ba9112..5840b6d 100644
--- a/apps/soundcraft-cli/src/main.rs
+++ b/apps/soundcraft-cli/src/main.rs
@@ -20,11 +20,12 @@ use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
-/// Print without panicking on a closed pipe.
+/// Print without panicking on a closed pipe. A write error must not skip later work
+/// such as `--bounce` or `--save`, and must not by itself report success.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
- if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
+ let _ = writeln!(o, $($t)*);
}};
}
@@ -294,12 +295,45 @@ fn describe(args: &[String]) -> ExitCode {
}
}
+/// Control-channel methods. Every other dotted id is an engine command.
+fn is_control_method(method: &str) -> bool {
+ matches!(
+ method,
+ "engine.execute"
+ | "command"
+ | "engine.commands"
+ | "engine.parity"
+ | "session.inspect"
+ | "document.inspect"
+ | "ui.inspect"
+ | "ui.menu.list"
+ | "ui.menu.invoke"
+ | "ui.set"
+ | "ui.move"
+ | "ui.click"
+ | "ui.drag"
+ | "ui.key"
+ | "ui.text"
+ | "ui.screenshot"
+ | "app.quit"
+ )
+}
+
+/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
+fn app_request(method: &str, params: Value) -> (String, Value) {
+ if is_control_method(method) || !method.contains('.') {
+ (method.to_string(), params)
+ } else {
+ ("engine.execute".to_string(), json!({"command": method, "params": params}))
+ }
+}
+
fn app(args: &[String]) -> ExitCode {
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
let positional: Vec<&String> = args
.iter()
.enumerate()
- .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))
+ .filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))))
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
@@ -311,17 +345,8 @@ fn app(args: &[String]) -> ExitCode {
None => json!({}),
};
let mut r = Remote::new(&port);
- // A bare command id is shorthand for engine.execute.
- let (m, p) = if method.contains('.')
- && !method.starts_with("ui.")
- && !method.starts_with("engine.")
- && !method.starts_with("session.")
- && !method.starts_with("app.")
- {
- ("engine.execute".to_string(), json!({"command": method, "params": params}))
- } else {
- (method.to_string(), params)
- };
+ // A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
+ let (m, p) = app_request(method, params);
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -351,7 +376,7 @@ fn mcp(args: &[String]) -> ExitCode {
fn parity(args: &[String]) -> ExitCode {
let p = soundcraft_engine::catalog::parity_json();
let mut md = String::new();
- md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n");
+ md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n");
md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n");
md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n");
md.push_str(&format!(
@@ -380,6 +405,23 @@ fn parity(args: &[String]) -> ExitCode {
mod tests {
use super::*;
+ #[test]
+ fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
+ let (m, p) = app_request("engine.history", json!({}));
+ assert_eq!(m, "engine.execute");
+ assert_eq!(p["command"], "engine.history");
+ let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
+ assert_eq!(m, "engine.execute");
+ assert_eq!(p["params"]["mode"], "light");
+ let (m, _) = app_request("session.new", json!({"name": "Session A"}));
+ assert_eq!(m, "engine.execute");
+ let (m, _) = app_request("track.new", json!({}));
+ assert_eq!(m, "engine.execute");
+ for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
+ assert_eq!(app_request(method, json!({})).0, method);
+ }
+ }
+
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/closed_stdout.rs
new file mode 100644
index 0000000..9807423
--- /dev/null
+++ b/apps/soundcraft-cli/tests/closed_stdout.rs
@@ -0,0 +1,124 @@
+//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
+
+use std::process::{Command, Stdio};
+
+fn bin() -> Command {
+ let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
+ cmd.env("SOUNDCRAFT_NO_PREFS", "1");
+ cmd
+}
+
+/// Drop the read end so the child's stdout writes fail with `EPIPE`.
+fn closed_stdout() -> std::process::Stdio {
+ let (reader, writer) = std::io::pipe().expect("pipe");
+ drop(reader);
+ Stdio::from(writer)
+}
+
+fn temp_dir(name: &str) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+#[test]
+fn run_with_closed_stdout_still_bounces_and_saves() {
+ let dir = temp_dir("run");
+ let wav = dir.join("mix.wav");
+ let session = dir.join("Synthetic.scraft");
+ let output = bin()
+ .arg("run")
+ .arg("--cmd")
+ .arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
+ .arg("--bounce")
+ .arg(&wav)
+ .arg("--save")
+ .arg(&session)
+ .arg("--start")
+ .arg("0")
+ .arg("--end")
+ .arg("0.01")
+ .stdout(closed_stdout())
+ .stderr(Stdio::piped())
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+ assert!(wav.is_file(), "bounce was skipped");
+ assert!(session.is_file(), "save was skipped");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn script_with_closed_stdout_still_bounces_and_saves() {
+ let dir = temp_dir("script");
+ let script = dir.join("edit.scraft.txt");
+ std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
+ let wav = dir.join("mix.wav");
+ let session = dir.join("Synthetic.scraft");
+ let output = bin()
+ .arg("script")
+ .arg(&script)
+ .arg("--bounce")
+ .arg(&wav)
+ .arg("--save")
+ .arg(&session)
+ .arg("--start")
+ .arg("0")
+ .arg("--end")
+ .arg("0.01")
+ .stdout(closed_stdout())
+ .stderr(Stdio::piped())
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+ assert!(wav.is_file());
+ assert!(session.is_file());
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn bounce_then_save_with_closed_stdout_writes_the_wav() {
+ let dir = temp_dir("bounce");
+ let wav = dir.join("mix.wav");
+ let session = dir.join("Empty.scraft");
+ let output = bin()
+ .arg("run")
+ .arg("--bounce")
+ .arg(&wav)
+ .arg("--save")
+ .arg(&session)
+ .arg("--start")
+ .arg("0")
+ .arg("--end")
+ .arg("0.01")
+ .stdout(closed_stdout())
+ .stderr(Stdio::piped())
+ .output()
+ .unwrap();
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+ assert!(wav.is_file());
+ assert!(session.is_file());
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn invalid_bounce_destination_still_fails() {
+ let dir = temp_dir("bad-bounce");
+ let missing = dir.join("no-such-dir").join("mix.wav");
+ let output = bin()
+ .arg("run")
+ .arg("--bounce")
+ .arg(&missing)
+ .arg("--start")
+ .arg("0")
+ .arg("--end")
+ .arg("0.01")
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .unwrap();
+ assert!(!output.status.success(), "a bounce that cannot be written must fail");
+ assert!(!missing.exists());
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml
index cb9dab1..783c7b5 100644
--- a/apps/soundcraft/Cargo.toml
+++ b/apps/soundcraft/Cargo.toml
@@ -28,3 +28,5 @@ winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
+objc2 = "0.6"
+objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs
new file mode 100644
index 0000000..1ade4ee
--- /dev/null
+++ b/apps/soundcraft/src/config.rs
@@ -0,0 +1,244 @@
+//! Per-user settings and automation profile directories.
+
+use std::ffi::OsString;
+use std::path::PathBuf;
+
+#[derive(Clone, Copy)]
+enum Platform {
+ Mac,
+ Windows,
+ Other,
+}
+
+/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
+/// The override is the complete directory; no application-name suffix is appended. An explicitly
+/// empty value disables the settings directory instead of falling back to a personal profile.
+pub fn config_dir() -> Option {
+ let platform = if cfg!(target_os = "macos") {
+ Platform::Mac
+ } else if cfg!(windows) {
+ Platform::Windows
+ } else {
+ Platform::Other
+ };
+ config_dir_with(platform, |name| std::env::var_os(name))
+}
+
+fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option {
+ if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
+ return (!dir.is_empty()).then(|| PathBuf::from(dir));
+ }
+ match platform {
+ Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
+ Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
+ Platform::Other => {
+ env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft"))
+ }
+ }
+}
+
+/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
+pub fn load_ui(path: &std::path::Path) -> Result, String> {
+ read_ui(path, true)
+}
+
+fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result , String> {
+ use std::io::Read as _;
+ let file = match std::fs::File::open(path) {
+ Ok(file) => file,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(format!("Settings could not be read: {error}")),
+ };
+ let mut bytes = Vec::new();
+ file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?;
+ if bytes.len() as u64 > MAX_UI_BYTES {
+ return Err("Settings file exceeds the 4 MiB limit".into());
+ }
+ let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
+ if recover_docking && let Some(fields) = saved.as_object_mut() {
+ for (field, mix) in [("docking", false), ("mix_docking", true)] {
+ let unreadable =
+ fields.get(field).is_some_and(|value| match serde_json::from_value::(value.clone()) {
+ Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
+ Err(_) => true,
+ });
+ if unreadable {
+ fields.remove(field);
+ }
+ }
+ }
+ let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
+ state.docking.validate()?;
+ state.mix_docking.validate_mix()?;
+ Ok(Some(state))
+}
+
+const MAX_UI_BYTES: u64 = 4 * 1024 * 1024;
+
+/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery.
+pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> {
+ use std::io::Write as _;
+ // Loading can recover a future layout for this session, but saving must preserve its source.
+ read_ui(path, false)?;
+ ui.docking.validate()?;
+ ui.mix_docking.validate_mix()?;
+ let mut saved = ui.clone();
+ saved.plugin_windows.clear();
+ saved.status.clear();
+ let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?;
+ if bytes.len() as u64 > MAX_UI_BYTES {
+ return Err("Settings exceed the 4 MiB limit".into());
+ }
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
+ }
+ let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos();
+ let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id()));
+ let mut file =
+ std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?;
+ let written = (|| -> std::io::Result<()> {
+ file.write_all(&bytes)?;
+ file.sync_all()?;
+ std::fs::rename(&temporary, path)
+ })();
+ if written.is_err() {
+ let _ = std::fs::remove_file(&temporary);
+ }
+ written.map_err(|error| format!("Settings could not be saved: {error}"))
+}
+
+#[cfg(test)]
+mod config_tests {
+ use super::*;
+
+ #[test]
+ fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() {
+ use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel};
+ let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id()));
+ let path = directory.join("ui.json");
+ let _ = std::fs::remove_dir_all(&directory);
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
+ app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap();
+ app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
+ let edit = serde_json::to_value(&app.ui.docking).unwrap();
+ app.ui.window = MainWindow::Mix;
+ app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
+ app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap();
+ save_ui(&path, &app.ui).unwrap();
+ app.ui = load_ui(&path).unwrap().unwrap();
+ assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
+ assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
+ assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
+ app.ui.window = MainWindow::Edit;
+ app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
+ assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
+ let mut invalid_mix = app.ui.clone();
+ invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
+ let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
+ std::fs::write(&path, &invalid_bytes).unwrap();
+ let recovered = load_ui(&path).unwrap().unwrap();
+ assert_eq!(recovered.theme, app.ui.theme);
+ assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
+ recovered.mix_docking.validate_mix().unwrap();
+ assert!(save_ui(&path, &app.ui).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
+ std::fs::write(&path, b"damaged settings").unwrap();
+ assert!(load_ui(&path).is_err());
+ assert!(save_ui(&path, &app.ui).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings");
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+
+ #[test]
+ fn future_edit_and_mix_layouts_preserve_other_preferences_and_the_original_file() {
+ use soundcraft_ui_egui::{Services, SoundApp};
+ let directory = std::env::temp_dir().join(format!("soundcraft-future-dock-prefs-{}", std::process::id()));
+ std::fs::create_dir_all(&directory).unwrap();
+ let path = directory.join("ui.json");
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap();
+ app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light;
+ app.ui.narrow_mix = true;
+ for field in ["docking", "mix_docking"] {
+ for invalid in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] {
+ let mut saved = serde_json::to_value(&app.ui).unwrap();
+ saved[field] = invalid;
+ assert!(serde_json::from_value::(saved.clone()).is_err());
+ let bytes = serde_json::to_vec(&saved).unwrap();
+ std::fs::write(&path, &bytes).unwrap();
+ let recovered = load_ui(&path).unwrap().unwrap();
+ assert_eq!(recovered.theme, app.ui.theme);
+ assert!(recovered.narrow_mix);
+ recovered.docking.validate().unwrap();
+ recovered.mix_docking.validate_mix().unwrap();
+ let other = if field == "docking" { "mix_docking" } else { "docking" };
+ assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
+ assert!(save_ui(&path, &recovered).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ }
+ }
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+
+ #[test]
+ fn oversized_settings_are_rejected_and_preserved() {
+ let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id()));
+ std::fs::create_dir_all(&directory).unwrap();
+ let path = directory.join("ui.json");
+ let bytes = vec![b' '; MAX_UI_BYTES as usize + 1];
+ std::fs::write(&path, &bytes).unwrap();
+ assert!(load_ui(&path).is_err());
+ assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err());
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+
+ fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option {
+ config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
+ }
+
+ #[test]
+ fn config_override_wins_without_reading_user_profile() {
+ for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
+ let actual = config_dir_with(platform, |name| {
+ assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
+ Some(OsString::from("isolated/settings"))
+ });
+ assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
+ }
+ }
+
+ #[test]
+ fn config_empty_override_disables_profile_without_reading_defaults() {
+ for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
+ assert_eq!(
+ config_dir_with(platform, |name| {
+ assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
+ Some(OsString::new())
+ }),
+ None
+ );
+ }
+ }
+
+ #[test]
+ fn config_unset_override_keeps_platform_defaults() {
+ let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
+ assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft")));
+ assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft")));
+ assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft")));
+ assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft")));
+ for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
+ assert_eq!(resolve(platform, &[]), None);
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn config_override_preserves_non_unicode_paths() {
+ use std::os::unix::ffi::OsStringExt as _;
+ let value = OsString::from_vec(b"isolated/\xff".to_vec());
+ assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
+ }
+}
diff --git a/apps/soundcraft/src/graphics.rs b/apps/soundcraft/src/graphics.rs
index ccd0c45..2a932a6 100644
--- a/apps/soundcraft/src/graphics.rs
+++ b/apps/soundcraft/src/graphics.rs
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
-use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
+use eframe::{
+ NativeOptions,
+ egui_wgpu::WgpuSetup,
+ wgpu::{Backends, Dx12Compiler},
+};
-/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
-/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
-pub fn configure(options: &mut NativeOptions, backend_override: Option) {
+/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
+/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
+pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
+ // Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships
+ // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
+ // indirect-validation pipeline and loses the device before the first frame
+ // (issue #185). FXC uses the compiler that is part of Windows.
+ create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
+ log::info!(
+ "Windows graphics: backends={:?}, DX12 compiler={:?}",
+ create.instance_descriptor.backends,
+ create.instance_descriptor.backend_options.dx12.shader_compiler
+ );
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option) -> Backends {
let mut options = NativeOptions::default();
- configure(&mut options, backend_override);
+ configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
+ #[test]
+ fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
+ let mut options = NativeOptions::default();
+ if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
+ // Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
+ create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
+ }
+ configure(&mut options, None, None);
+ let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
+ panic!("default native options must create a wgpu instance");
+ };
+ assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
+ }
+
+ #[test]
+ fn explicit_compiler_override_is_preserved() {
+ for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
+ let mut options = NativeOptions::default();
+ configure(&mut options, None, Some(compiler.clone()));
+ let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
+ panic!("default native options must create a wgpu instance");
+ };
+ match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
+ (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
+ (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
+ assert_eq!(actual, expected);
+ }
+ (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
+ }
+ }
+ }
+
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs
index 87da4f0..8004acf 100644
--- a/apps/soundcraft/src/main.rs
+++ b/apps/soundcraft/src/main.rs
@@ -11,6 +11,7 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+mod config;
mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
@@ -97,16 +98,7 @@ fn open_path(app: &mut SoundApp, p: &str) {
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
fn config_dir() -> Option {
- if cfg!(target_os = "macos") {
- std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft"))
- } else if cfg!(windows) {
- std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft"))
- } else {
- std::env::var_os("XDG_CONFIG_HOME")
- .map(std::path::PathBuf::from)
- .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
- .map(|c| c.join("soundcraft"))
- }
+ config::config_dir()
}
fn prefs_path() -> Option {
@@ -122,24 +114,24 @@ fn load_prefs() -> Option {
if !prefs_enabled() {
return None;
}
- let bytes = std::fs::read(prefs_path()?).ok()?;
- serde_json::from_slice(&bytes).ok()
+ let path = prefs_path()?;
+ match config::load_ui(&path) {
+ Ok(state) => state,
+ Err(error) => {
+ log::warn!("{error}");
+ None
+ }
+ }
}
fn save_prefs(ui: &UiState) {
if !prefs_enabled() {
return;
}
- if let Some(p) = prefs_path() {
- if let Some(dir) = p.parent() {
- let _ = std::fs::create_dir_all(dir);
- }
- let mut ui = ui.clone();
- ui.plugin_windows.clear();
- ui.status.clear();
- if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
- let _ = std::fs::write(&p, bytes);
- }
+ if let Some(path) = prefs_path()
+ && let Err(error) = config::save_ui(&path, ui)
+ {
+ log::warn!("{error}");
}
}
@@ -198,7 +190,7 @@ fn main() -> eframe::Result {
let files: Vec = args
.iter()
.enumerate()
- .filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))
+ .filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))))
.map(|(_, a)| a.clone())
.collect();
log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
@@ -215,7 +207,7 @@ fn main() -> eframe::Result {
},
None => {
logger.no_file();
- log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)");
+ log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)");
}
}
}
@@ -233,9 +225,9 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
- // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
+ // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
- graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
+ graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
eframe::run_native(
"SoundCraft",
options,
diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs
index 9cc72de..4bd085a 100644
--- a/apps/soundcraft/src/native_menu.rs
+++ b/apps/soundcraft/src/native_menu.rs
@@ -1,5 +1,7 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
-use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
+use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
+use objc2::MainThreadMarker;
+use objc2_app_kit::NSApplication;
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
@@ -36,8 +38,9 @@ impl NativeMenu {
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
- app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
- app_menu.append(&PredefinedMenuItem::hide_others(None))?;
+ // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
+ app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
+ app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
@@ -82,7 +85,12 @@ impl NativeMenu {
while let Ok(event) = self.events.try_recv() {
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
self.last_sync = None;
- if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
+ if (event.id == "hide" || event.id == "hide_others")
+ && let Some(mtm) = MainThreadMarker::new()
+ {
+ let ns = NSApplication::sharedApplication(mtm);
+ if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
+ } else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
diff --git a/contributors/contributors.json b/contributors/contributors.json
index 3370da8..5b4969d 100644
--- a/contributors/contributors.json
+++ b/contributors/contributors.json
@@ -2,23 +2,192 @@
"schema": 1,
"repo": "storytold/soundcraft",
"branch": "main",
- "head": "3597f895cc49ad795650839c1eeeeac0f88e2aa9",
- "head_date": "2026-10-07T18:45:52Z",
- "commits": 49,
+ "head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685",
+ "head_date": "2026-10-10T18:08:49Z",
+ "commits": 111,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
- "prs": 1,
- "commits": 49,
- "lines_added": 78099,
- "lines_deleted": 1928,
+ "prs": 6,
+ "commits": 58,
+ "lines_added": 80505,
+ "lines_deleted": 2222,
"binary_added": 23,
"binary_deleted": 0,
"first_commit": "2026-10-06T00:08:04Z",
- "last_commit": "2026-10-07T18:45:52Z"
+ "last_commit": "2026-10-10T18:08:49Z"
+ },
+ {
+ "login": "reoniazx",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 5,
+ "commits": 9,
+ "lines_added": 649,
+ "lines_deleted": 64,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T06:10:03Z",
+ "last_commit": "2026-10-10T05:15:18Z"
+ },
+ {
+ "login": "switch87",
+ "people_entry": true,
+ "github_name": null,
+ "prs": 1,
+ "commits": 2,
+ "lines_added": 981,
+ "lines_deleted": 18,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T10:25:49Z",
+ "last_commit": "2026-10-08T10:27:07Z"
+ },
+ {
+ "login": "h-a-n-a-s-h-i",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 0,
+ "commits": 3,
+ "lines_added": 65,
+ "lines_deleted": 62,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T12:13:25Z",
+ "last_commit": "2026-10-08T14:02:25Z"
+ },
+ {
+ "login": "XusBadia",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 243,
+ "lines_deleted": 1,
+ "binary_added": 2,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T13:12:32Z",
+ "last_commit": "2026-10-08T13:12:32Z"
+ },
+ {
+ "login": "ebbicott",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 2,
+ "commits": 5,
+ "lines_added": 791,
+ "lines_deleted": 97,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T17:45:32Z",
+ "last_commit": "2026-10-08T19:40:37Z"
+ },
+ {
+ "login": "MarekSpodBiedry",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 6,
+ "commits": 7,
+ "lines_added": 274,
+ "lines_deleted": 35,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T18:06:42Z",
+ "last_commit": "2026-10-09T19:25:52Z"
+ },
+ {
+ "login": "Garygunn94",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 4,
+ "commits": 7,
+ "lines_added": 386,
+ "lines_deleted": 38,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-08T20:24:47Z",
+ "last_commit": "2026-10-09T15:05:24Z"
+ },
+ {
+ "login": "vbasky",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 10,
+ "commits": 11,
+ "lines_added": 1039,
+ "lines_deleted": 174,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-09T00:12:26Z",
+ "last_commit": "2026-10-10T05:22:24Z"
+ },
+ {
+ "login": "DWAA1660",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 67,
+ "lines_deleted": 0,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-09T00:58:22Z",
+ "last_commit": "2026-10-09T00:58:22Z"
+ },
+ {
+ "login": "hamada147",
+ "people_entry": true,
+ "github_name": "Ahmed Moussa",
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 27,
+ "lines_deleted": 0,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-09T08:49:05Z",
+ "last_commit": "2026-10-09T08:49:05Z"
+ },
+ {
+ "login": "salignatmoandal",
+ "people_entry": true,
+ "github_name": "where's Mawen?",
+ "prs": 2,
+ "commits": 3,
+ "lines_added": 119,
+ "lines_deleted": 6,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-09T19:03:36Z",
+ "last_commit": "2026-10-09T19:24:17Z"
+ },
+ {
+ "login": "grovebit",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 2,
+ "commits": 2,
+ "lines_added": 218,
+ "lines_deleted": 15,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-09T21:45:39Z",
+ "last_commit": "2026-10-10T01:34:26Z"
+ },
+ {
+ "login": "coygeek",
+ "people_entry": false,
+ "github_name": null,
+ "prs": 1,
+ "commits": 1,
+ "lines_added": 125,
+ "lines_deleted": 91,
+ "binary_added": 0,
+ "binary_deleted": 0,
+ "first_commit": "2026-10-10T01:28:07Z",
+ "last_commit": "2026-10-10T01:28:07Z"
}
],
"models": [
@@ -27,9 +196,18 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
- "commits": 47,
- "lines_added": 78091,
- "lines_deleted": 1927
+ "commits": 73,
+ "lines_added": 82279,
+ "lines_deleted": 2544
+ },
+ {
+ "id": "anthropic/claude-haiku/5.5",
+ "company": "Anthropic",
+ "model": "Claude Haiku",
+ "version": "5.5",
+ "commits": 5,
+ "lines_added": 368,
+ "lines_deleted": 35
}
]
}
diff --git a/contributors/people.toml b/contributors/people.toml
index 5079ae9..bc8bd31 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -35,3 +35,8 @@ sync_github_name = false
[people.salignatmoandal]
real_name = "Mawen Salignat-Moandal"
sync_github_name = true
+
+[people.vbasky]
+real_name = "Vikram Bhaskaran"
+display_name = "Vikram Bhaskaran"
+sync_github_name = false
diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs
index 1b38dcc..19fe91a 100644
--- a/crates/automation/src/mcp.rs
+++ b/crates/automation/src/mcp.rs
@@ -127,11 +127,20 @@ impl Server {
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
- let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
+ let Some(calls) = args.get("calls").and_then(Value::as_array) else {
+ return error_result("`calls` must be an array");
+ };
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
- let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
+ let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
+ out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
+ failed = true;
+ if !keep {
+ break;
+ }
+ continue;
+ };
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
match r {
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
@@ -144,7 +153,9 @@ impl Server {
}
}
}
- if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
+ // Any failure sets isError so agents that only check that flag notice;
+ // keep_going still runs the rest and the payload lists every call.
+ if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs
index 04fe256..3636cfe 100644
--- a/crates/automation/tests/agent_tasks.rs
+++ b/crates/automation/tests/agent_tasks.rs
@@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() {
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
}
+
+#[test]
+fn batch_keep_going_still_sets_is_error_on_partial_failure() {
+ let mut s = Server::new(Headless::new(Engine::default()));
+ let r = rpc(
+ &mut s,
+ 1,
+ "tools/call",
+ json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
+ {"command": "track.new", "params": {"count": 1, "name": "Kept"}},
+ {"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
+ {"command": "track.new", "params": {"count": 1, "name": "After"}},
+ ]}}),
+ );
+ assert_eq!(r["result"]["isError"], true, "{r}");
+ let text = r["result"]["content"][0]["text"].as_str().unwrap();
+ let results: Value = serde_json::from_str(text).unwrap();
+ let arr = results.as_array().unwrap();
+ assert_eq!(arr.len(), 3, "{results}");
+ assert_eq!(arr[0]["ok"], true);
+ assert_eq!(arr[1]["ok"], false);
+ assert_eq!(arr[2]["ok"], true);
+ let sess = session(&mut s);
+ assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
+ assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
+}
+
+#[test]
+fn batch_rejects_non_array_calls() {
+ let mut s = Server::new(Headless::new(Engine::default()));
+ let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
+ assert_eq!(r["result"]["isError"], true, "{r}");
+ let text = r["result"]["content"][0]["text"].as_str().unwrap();
+ assert!(text.contains("`calls` must be an array"), "{text}");
+ assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
+}
diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs
index 6cbac6f..16aaf33 100644
--- a/crates/clap-host/src/ffi.rs
+++ b/crates/clap-host/src/ffi.rs
@@ -71,8 +71,9 @@ unsafe fn read_cstr(p: *const c_char) -> Option {
}
/// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array.
+/// `c_char` is `i8` on x86 and `u8` on ARM/RISC-V Linux; the byte reinterpretation is the same for both.
fn read_name(buf: &[c_char]) -> String {
- let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect();
+ let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| u8::from_ne_bytes(c.to_ne_bytes())).collect();
String::from_utf8_lossy(&bytes).into_owned()
}
diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs
index ac26a30..e2abfca 100644
--- a/crates/engine/src/cmd/audiosuite.rs
+++ b/crates/engine/src/cmd/audiosuite.rs
@@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
- nc.gain_db = 0.0;
- nc.gain_env.clear();
+ // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
+ // the same way its fades stay editable. Other processes bake a new file at unity gain.
+ if !matches!(name.as_str(), "invert" | "duplicate") {
+ nc.gain_db = 0.0;
+ nc.gain_env.clear();
+ }
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
pl.clips.retain(|c| c.id != id);
}
@@ -122,3 +126,43 @@ fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join("");
s.chars().take(8).collect()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use soundcraft_audio_io::AudioBuffer;
+ use soundcraft_model::{ChannelFormat, Session, TrackKind};
+
+ fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
+ let mut s = Session::default();
+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
+ let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
+ let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
+ let id = s.new_clip_id();
+ let mut c = Clip::audio(id, "tone", src, 0, 0, 64);
+ c.gain_db = -6.0;
+ c.gain_env = vec![(0, 0.0), (32, -12.0)];
+ crate::edit::place_clip(&mut s, t, c);
+ s.edit.selected_clips = vec![id];
+ (crate::Engine::new(s), id)
+ }
+
+ #[test]
+ fn invert_and_duplicate_keep_clip_gain() {
+ let (mut e, id) = gained_clip();
+ e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap();
+ let c = e.session().tracks[0].clips()[0].clone();
+ assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
+ assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
+ let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
+ assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}");
+
+ let (mut e, id) = gained_clip();
+ e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap();
+ let c = e.session().tracks[0].clips()[0].clone();
+ assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
+ assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
+ let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
+ assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
+ }
+}
diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs
index cca8102..74dfd8d 100644
--- a/crates/engine/src/cmd/clip_more.rs
+++ b/crates/engine/src/cmd/clip_more.rs
@@ -362,4 +362,69 @@ mod tests {
assert_eq!(c.length, 18_000);
assert_ne!(c.source(), Some(src));
}
+
+ fn peak_from(ch: &[f32], start: usize) -> f32 {
+ ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs()))
+ }
+
+ fn max_abs_diff(a: &[Vec], b: &[Vec]) -> f32 {
+ a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max)
+ }
+
+ /// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result.
+ #[test]
+ fn render_preserves_stretched_audio() {
+ for command in ["clip.gain_render", "clip.effects_render"] {
+ for ratio in [0.5_f64, 1.0, 2.0] {
+ let mut s = Session::default();
+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
+ let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
+ let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
+ let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
+ let id = s.new_clip_id();
+ crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
+ s.edit.selected_clips = vec![id];
+ let mut e = Engine::new(s);
+ e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
+ e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap();
+ if command == "clip.effects_render" {
+ e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap();
+ }
+ let range = e.session().find_clip(id).unwrap().1.range();
+ let before = soundcraft_mix::render_clips(e.session(), t, range);
+ e.execute(command, &json!({"clip": id.0})).unwrap();
+ let c = e.session().find_clip(id).unwrap().1.clone();
+ assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch);
+ assert_eq!(c.gain_db, 0.0);
+ let after = soundcraft_mix::render_clips(e.session(), t, range);
+ let diff = max_abs_diff(&before, &after);
+ assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}");
+ if ratio < 1.0 {
+ let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0);
+ assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}");
+ }
+ }
+ }
+
+ // Varispeed on a non-elastic track does not change the clip length. Render still matches it.
+ let mut s = Session::default();
+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
+ let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
+ let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
+ let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
+ let id = s.new_clip_id();
+ crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
+ s.edit.selected_clips = vec![id];
+ let mut e = Engine::new(s);
+ e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap();
+ e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
+ let range = e.session().find_clip(id).unwrap().1.range();
+ let before = soundcraft_mix::render_clips(e.session(), t, range);
+ e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap();
+ let c = e.session().find_clip(id).unwrap().1.clone();
+ assert_eq!(c.length, 9_600);
+ assert!((c.stretch - 1.0).abs() < 1e-9);
+ let after = soundcraft_mix::render_clips(e.session(), t, range);
+ assert!(max_abs_diff(&before, &after) < 1e-5);
+ }
}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 9d8d7f0..dd49ed4 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -25,7 +25,9 @@ pub fn specs() -> Vec {
p,
"edit.clear_clip_gain"
)),
- cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
+ cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{tracks?, start?, end?}", has_range, |e, p| {
+ super::edit_more::cut_all_automation(e, p)
+ }),
cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
e,
diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs
index d27bb97..c5c1082 100644
--- a/crates/engine/src/cmd/edit_more.rs
+++ b/crates/engine/src/cmd/edit_more.rs
@@ -277,6 +277,10 @@ fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result {
if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) }
}
+pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result {
+ auto_copy(e, p, Lanes::All, true, "edit.cut_all_automation")
+}
+
fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
@@ -663,8 +667,10 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result {
let mut moved = 0;
for (id, _) in order {
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
- let others =
- s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id && !ids.contains(&x.id)).cloned().collect::>()).unwrap_or_default();
+ // Selected neighbours are real destinations. Ignoring them made Snap to Next
+ // pile every selected clip onto the same later clip, and Snap to Previous
+ // leave the gap between the selected clips in place.
+ let others = s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id).cloned().collect::>()).unwrap_or_default();
let delta = if next {
others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end())
} else {
@@ -982,6 +988,34 @@ mod tests {
assert_eq!(starts(&e, t), vec![0, 900, 1000]);
}
+ #[test]
+ fn cut_all_automation_can_be_pasted() {
+ let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
+ e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 100, "value": -6.0})).unwrap();
+ e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 400, "value": -12.0})).unwrap();
+ let cut = e.execute("edit.cut_all_automation", &json!({"start": 50, "end": 250})).unwrap();
+ assert!(cut["lanes"].as_u64().unwrap() >= 1, "{cut}");
+ assert!(e.clipboard.automation.iter().any(|lanes| !lanes.is_empty()));
+ let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
+ assert!(lane.points.iter().all(|p| p.at != 100), "the cut point is gone: {:?}", lane.points);
+ e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap();
+ let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
+ assert!(vol.points.iter().any(|p| p.at >= 1000 && (p.value + 6.0).abs() < 1e-2), "paste wrote nothing at -6 dB: {:?}", vol.points);
+ }
+
+ #[test]
+ fn snap_selected_neighbours_keeps_both_clips() {
+ let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
+ e.execute("edit.snap_next", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
+ assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
+ assert_eq!(starts(&e, t), vec![800, 900, 1000]);
+
+ let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
+ e.execute("edit.snap_previous", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
+ assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
+ assert_eq!(starts(&e, t), vec![0, 100, 1000]);
+ }
+
#[test]
fn copy_and_paste_automation_only() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 796ad8f..30145e9 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -27,9 +27,7 @@ pub fn specs() -> Vec {
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
- let keep = e.path.clone();
- let written = crate::io::save_session(e, &path)?;
- e.path = keep;
+ let written = crate::io::save_session_copy(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
@@ -98,7 +96,8 @@ pub fn specs() -> Vec {
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
- let bytes = crate::io::export_midi(e)?;
+ let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None };
+ let bytes = crate::io::export_midi(e, only.as_deref())?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
@@ -234,6 +233,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result> {
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
}
+/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error.
+fn bounce_bus(e: &Engine, p: &Value) -> Result > {
+ let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else {
+ return Ok(None);
+ };
+ if name.eq_ignore_ascii_case("main") {
+ return Ok(None);
+ }
+ e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`")))
+}
+
/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix.
fn fold_down_param(p: &Value, id: &str) -> Result {
match str_param(p, "fold_down").map(str::trim) {
@@ -260,7 +270,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result {
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
- let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
+ let bus = bounce_bus(e, p)?;
+ let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
@@ -365,6 +376,53 @@ mod tests {
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
+ #[test]
+ fn bounce_source_renders_the_named_bus() {
+ let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let wav = tone_wav(&dir, "tone.wav");
+ let mut e = Engine::default();
+ e.execute("file.import_audio", &json!({"path": wav})).unwrap();
+ let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
+ e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
+ let track = e.session().tracks[0].id;
+ {
+ let tr = e.session_mut().track_mut(track).unwrap();
+ let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
+ send.level_db = -12.0;
+ tr.mixer.sends[0] = Some(send);
+ }
+ let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
+ let path = dir.join(name);
+ let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
+ if let Some(source) = source {
+ params["source"] = json!(source);
+ }
+ let value = e.execute("file.bounce_mix", ¶ms).unwrap();
+ let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
+ (std::fs::read(&path).unwrap(), peak)
+ };
+ let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
+ let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
+ let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
+ let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
+ assert_eq!(main_bytes, named_main);
+ assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
+ assert!(main_peak > -20.0, "{main_peak}");
+ assert_ne!(stem_bytes, main_bytes);
+ assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
+ assert_ne!(empty_bytes, main_bytes);
+ assert!(empty_peak < -100.0, "{empty_peak}");
+ let missing = dir.join("missing.wav");
+ let err = e
+ .execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
+ .unwrap_err();
+ assert!(err.to_string().contains("unknown bus"), "{err}");
+ assert!(!missing.exists());
+ assert!(std::fs::remove_dir_all(&dir).is_ok());
+ }
+
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
@@ -390,4 +448,177 @@ mod tests {
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
+ fn tone_engine() -> (Engine, u64) {
+ let mut e = Engine::default();
+ e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
+ let track = e.session().tracks[0].id;
+ let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
+ let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
+ let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
+ let id = e.session_mut().new_clip_id();
+ crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
+ e.session_mut().edit.selected_tracks = vec![track];
+ (e, track.0)
+ }
+
+ #[test]
+ fn same_named_clips_export_as_separate_files() {
+ let (mut e, track) = tone_engine();
+ let first = e.session().tracks[0].clips()[0].id;
+ let src = e.session().tracks[0].clips()[0].source().unwrap();
+ let second = e.session_mut().new_clip_id();
+ crate::edit::place_clip(
+ e.session_mut(),
+ soundcraft_model::TrackId(track),
+ soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800),
+ );
+ for id in [first, second] {
+ e.session_mut().find_clip_mut(id).unwrap().name = "Take".into();
+ }
+ let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap();
+ assert_eq!(n["written"], 2);
+ let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
+ wavs.sort();
+ assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ fn midi_engine() -> Engine {
+ use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
+ use soundcraft_model::{ChannelFormat, Clip, TrackKind};
+ let mut s = Session::default();
+ let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
+ let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
+ let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
+ let mut keys_seq = Sequence::default();
+ keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
+ keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
+ let mut drum_seq = Sequence::default();
+ drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
+ drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
+ let a = s.new_clip_id();
+ let b = s.new_clip_id();
+ crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
+ crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
+ Engine::new(s)
+ }
+
+ fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
+ let smf = soundcraft_midi::read_smf(bytes).unwrap();
+ let mut out = Vec::new();
+ for t in &smf.tracks {
+ for c in &t.sequence.ctrls {
+ if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
+ out.push((t.name.clone(), n, c.value));
+ }
+ }
+ }
+ out
+ }
+
+ #[test]
+ fn midi_export_keeps_controllers_and_honours_tracks() {
+ let mut e = midi_engine();
+ let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let all = dir.join("all.mid");
+ e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
+ let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
+ assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
+ assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
+ assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
+
+ let keys = dir.join("keys.mid");
+ e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
+ let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
+ assert_eq!(ctrls.len(), 1, "{ctrls:?}");
+ assert_eq!(ctrls[0].0, "Keys");
+
+ let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
+ assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn import_surround_creates_a_matching_track() {
+ let channels: Vec> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
+ let buf = AudioBuffer { sample_rate: 48_000, channels };
+ let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
+ let mut e = Engine::default();
+ let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
+ assert_eq!(v["channels"], 6);
+ assert_eq!(e.session().tracks.len(), 1);
+ assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
+ let src = e.session().sources[0].id;
+ let audio = e.session().pool.get(src).unwrap();
+ assert_eq!(audio.buffer.channels.len(), 6);
+ assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
+
+ let mut e = Engine::default();
+ e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
+ let id = e.session().tracks[0].id;
+ crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
+ assert_eq!(e.session().tracks.len(), 1);
+ assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
+ assert_eq!(e.session().tracks[0].clips().len(), 1);
+ }
+
+ #[test]
+ fn repeated_consolidate_keeps_distinct_audio_files() {
+ let (mut e, track) = tone_engine();
+ e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
+ let cid = e.session().tracks[0].clips()[0].id;
+ e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
+ e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
+ let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
+ assert_eq!(consolidated.len(), 2, "{consolidated:?}");
+ assert_ne!(consolidated[0], consolidated[1]);
+
+ let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("Tone.scraft");
+ e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
+ let mut opened = Engine::default();
+ opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
+ let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
+ assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
+ let a = std::fs::read(&files[0]).unwrap();
+ let b = std::fs::read(&files[1]).unwrap();
+ assert_ne!(a, b, "the second consolidation replaced the first file");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn save_copy_leaves_the_open_session_alone() {
+ let (mut e, _) = tone_engine();
+ let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let original = dir.join("Original.scraft");
+ e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
+ e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
+ assert!(e.is_dirty());
+ let copy_dir = dir.join("copy");
+ std::fs::create_dir_all(©_dir).unwrap();
+ let copy = copy_dir.join("Copy.scraft");
+ e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
+ assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
+ assert_eq!(e.session().name, "Original");
+ assert!(e.is_dirty());
+ assert_eq!(e.session().tracks.len(), 2);
+
+ let mut from_original = Engine::default();
+ from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
+ assert_eq!(from_original.session().tracks.len(), 1);
+ let mut from_copy = Engine::default();
+ from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
+ assert_eq!(from_copy.session().tracks.len(), 2);
+ assert!(from_copy.session().track_by_name("Extra").is_some());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs
index 03574b2..3a01d3d 100644
--- a/crates/engine/src/cmd/mix.rs
+++ b/crates/engine/src/cmd/mix.rs
@@ -534,25 +534,95 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result {
Ok(json!({}))
}
+/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
+fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
+ let mut prev: Option<(i64, f32)> = None;
+ for &(t, v) in env {
+ if t >= at {
+ return match prev {
+ Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
+ _ => v,
+ };
+ }
+ prev = Some((t, v));
+ }
+ prev.map_or(0.0, |(_, v)| v)
+}
+
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result {
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
let s = e.session_mut();
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
- let lane = tr.lane(&AutoParam::Volume).cloned();
+ let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let base = tr.mixer.volume_db;
- let Some(lane) = lane else { continue };
+ let delta = |at: i64| lane.value_at(at, base) - base;
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
- let mut env: Vec<(i64, f32)> =
- lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect();
- env.insert(0, (0, lane.value_at(c.start, base) - base));
- env.push((c.length, lane.value_at(c.end(), base) - base));
+ let sel0 = r.start.max(c.start);
+ let sel1 = r.end.min(c.end());
+ if sel1 <= sel0 {
+ continue;
+ }
+ let rel = |at: i64| at - c.start;
+ let rel0 = rel(sel0);
+ let rel1 = rel(sel1);
+ // Later writes win, so the selection anchors replace any point they land on.
+ let mut pts: Vec<(i64, f32)> =
+ c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
+ if rel0 > 0 {
+ pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
+ }
+ pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
+ for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
+ let at = rel(pt.at);
+ pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
+ }
+ // The clip's own gain points inside the selection keep their shape, offset by the lane.
+ for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
+ pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
+ }
+ if sel1 - 1 > sel0 {
+ let at = rel(sel1 - 1);
+ pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
+ }
+ if sel1 < c.end() {
+ pts.push((rel1, env_db(&c.gain_env, rel1)));
+ }
+ pts.sort_by_key(|p| p.0);
+ let mut env = Vec::new();
+ for p in pts {
+ if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
+ env.pop();
+ }
+ env.push(p);
+ }
c.gain_env = env;
}
}
- tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
+ // Clearing the lane is only safe when no audio plays outside the selection; otherwise the
+ // held edge values outside would fall back to the fader level.
+ let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
+ let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
+ let lane_m = tr.lane_mut(&AutoParam::Volume);
+ if covers_all {
+ lane_m.clear_range(r.start, r.end);
+ } else {
+ // Hold the fader across the selection and anchor the samples on either side, so the
+ // curve outside does not interpolate through the gap or keep the old level inside.
+ let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
+ let after = lane.value_at(r.end, base);
+ lane_m.clear_range(r.start, r.end);
+ if let Some(v) = before {
+ lane_m.set_point(r.start - 1, v);
+ }
+ lane_m.set_point(r.start, base);
+ if r.end - 1 > r.start {
+ lane_m.set_point(r.end - 1, base);
+ }
+ lane_m.set_point(r.end, after);
+ }
}
Ok(json!({}))
}
@@ -606,3 +676,119 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result {
}
Ok(json!({}))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use soundcraft_audio_io::AudioBuffer;
+ use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
+ use soundcraft_time::Range;
+ use std::sync::Arc;
+
+ fn rms_db(before: &[f32], after: &[f32]) -> f32 {
+ let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::() / xs.len().max(1) as f32;
+ 10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
+ }
+
+ fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
+ let mut s = Session::default();
+ let n = 8192;
+ let tone: Vec = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
+ s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
+ let id = s.new_clip_id();
+ s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
+ s.track_mut(t).unwrap().mixer.volume_db = -3.0;
+ for &(at, value) in points {
+ s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
+ }
+ s.edit.selected_tracks = vec![t];
+ s.edit.selection = Range::new(2048, 6144);
+ (Engine::new(s), t)
+ }
+
+ fn rendered(s: &Session) -> Vec {
+ soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
+ }
+
+ fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
+ e.session_mut().edit.selection = Range::new(start, end);
+ e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
+ }
+
+ #[test]
+ fn partial_volume_to_clip_gain_keeps_each_region() {
+ let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
+ let (mut e, t) = tone_session(&points);
+ let before = rendered(e.session());
+ convert(&mut e, t, 2048, 6144);
+ let after = rendered(e.session());
+ for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
+ let delta = rms_db(&before[start..end], &after[start..end]);
+ assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
+ }
+ let tr = e.session().track(t).unwrap();
+ assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
+ let lane = tr.lane(&AutoParam::Volume).unwrap();
+ assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
+ assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
+ assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
+
+ let (mut e, t) = tone_session(&[]);
+ let before = rendered(e.session());
+ convert(&mut e, t, 2048, 6144);
+ let after = rendered(e.session());
+ let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
+ assert!(diff < 1e-5, "no lane changed a sample by {diff}");
+
+ let (mut e, t) = tone_session(&points);
+ let before = rendered(e.session());
+ convert(&mut e, t, 0, 8193);
+ let after = rendered(e.session());
+ let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
+ assert!(diff < 1e-4, "full selection changed a sample by {diff}");
+ assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
+
+ for points in [
+ [(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)],
+ [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)],
+ ] {
+ let (mut e, t) = tone_session(&points);
+ let before = rendered(e.session());
+ convert(&mut e, t, 2048, 6144);
+ let after = rendered(e.session());
+ for (start, end) in [(0, 2048), (6144, 8192)] {
+ let delta = rms_db(&before[start..end], &after[start..end]);
+ assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
+ }
+ let delta = rms_db(&before[2048..6144], &after[2048..6144]);
+ assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
+ }
+ }
+
+ #[test]
+ fn lane_points_all_inside_the_selection_keep_the_outside_level() {
+ let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
+ let before = rendered(e.session());
+ convert(&mut e, t, 2048, 6144);
+ let after = rendered(e.session());
+ for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
+ let delta = rms_db(&before[start..end], &after[start..end]);
+ assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
+ }
+ }
+
+ #[test]
+ fn clip_gain_points_inside_the_selection_are_kept() {
+ let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
+ let clip = e.session().track(t).unwrap().clips()[0].id;
+ e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
+ let before = rendered(e.session());
+ convert(&mut e, t, 2048, 6144);
+ let after = rendered(e.session());
+ for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
+ let delta = rms_db(&before[start..end], &after[start..end]);
+ assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
+ }
+ }
+}
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 53d38e2..18f1de6 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -3,7 +3,7 @@
use crate::{Engine, EngineError, Result};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
-use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
+use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -11,6 +11,21 @@ use std::sync::Arc;
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
+/// A relative `Audio Files/` path no other source in `s` already uses.
+fn unique_audio_rel(s: &Session, stem: &str) -> String {
+ let stem = sanitize_name(stem);
+ let mut path = format!("Audio Files/{stem}.wav");
+ let mut n = 2u32;
+ while s.sources.iter().any(|src| src.path == path) {
+ path = format!("Audio Files/{stem}-{n}.wav");
+ n = n.saturating_add(1);
+ if n > 10_000 {
+ break;
+ }
+ }
+ path
+}
+
/// Register decoded audio as a new source. Resamples to the session rate if needed.
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
let sr = s.sample_rate.hz();
@@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
}
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
+ let stored = match path {
+ Some(p) => p.to_string(),
+ None => unique_audio_rel(s, &stem),
+ };
s.sources.push(Source {
id,
name: stem.clone(),
- path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
+ path: stored,
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
@@ -56,7 +75,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
Some(t) => t,
None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
- s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
+ s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
let cid = s.new_clip_id();
@@ -180,8 +199,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
Ok(out)
}
-/// Export all MIDI clips as a format-1 SMF.
-pub fn export_midi(e: &Engine) -> Result> {
+/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks.
+pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
@@ -191,16 +210,25 @@ pub fn export_midi(e: &Engine) -> Result> {
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
key_sigs: Vec::new(),
};
- for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
+ let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect();
+ if only.is_some() && chosen.is_empty() {
+ return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into()));
+ }
+ for t in chosen {
let mut seq = soundcraft_midi::Sequence::default();
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
let mut n = *n;
- n.start += base;
+ n.start = n.start.saturating_add(base);
seq.notes.push(n);
}
+ for ev in &sequence.ctrls {
+ let mut ev = *ev;
+ ev.tick = ev.tick.saturating_add(base);
+ seq.ctrls.push(ev);
+ }
}
}
seq.sort();
@@ -217,11 +245,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> {
+ bounce_source(e, r, opts, normalize, fold_stereo, None)
+}
+
+/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
+pub fn bounce_source(
+ e: &Engine,
+ r: Range,
+ opts: &EncodeOptions,
+ normalize: bool,
+ fold_stereo: bool,
+ bus: Option,
+) -> Result<(Vec, (f32, f32, f32))> {
let s = e.session();
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
}
- let (mut ch, fmt) = render_main(s, r, fold_stereo);
+ let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
if normalize {
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
}
@@ -238,8 +278,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize:
/// The main mix over `r` and its format (folded to stereo on request).
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) {
- let ch = soundcraft_mix::render_range(s, r, 1024);
- let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
+ render_bounce(s, r, fold_stereo, None)
+}
+
+/// The main mix, or one bus, over `r`.
+fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) {
+ let (ch, fmt) = if let Some(id) = bus {
+ let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
+ (soundcraft_mix::render_bus(s, id, r, 1024), fmt)
+ } else {
+ let ch = soundcraft_mix::render_range(s, r, 1024);
+ let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
+ (ch, fmt)
+ };
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
}
@@ -379,6 +430,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<
cl.content = ClipContent::Audio { source: src, offset: 0 };
cl.gain_db = 0.0;
cl.gain_env.clear();
+ // The new source is the clip as it already sounds, stretch included.
+ // Keeping the old ratio would play that audio through the stretch again.
+ cl.stretch = 1.0;
}
Ok(true)
}
@@ -432,7 +486,16 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
let audio = soundcraft_mix::render_clips(s, t, c.range());
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
- let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name)));
+ let stem = sanitize_name(&c.name);
+ let mut path = Path::new(dir).join(format!("{stem}.wav"));
+ let mut k = 2u32;
+ while path.exists() {
+ path = Path::new(dir).join(format!("{stem}-{k}.wav"));
+ k = k.saturating_add(1);
+ if k > 10_000 {
+ return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
+ }
+ }
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
n += 1;
}
@@ -462,6 +525,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
+/// File › Save Session Copy In: a normal session folder that does not rename, retarget or
+/// clean the session that stays open.
+pub fn save_session_copy(e: &Engine, path: &str) -> Result {
+ write_session(e.session(), path, false).map(|(_, n, _)| n)
+}
+
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
@@ -471,10 +540,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
let mut written = 0;
- for src in &mut s.sources {
+ let shared_paths: Vec =
+ s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect();
+ for (i, src) in s.sources.iter_mut().enumerate() {
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
- if exists_rel && !src.unsaved {
+ let shared = shared_paths.get(i).copied().unwrap_or(false);
+ if exists_rel && !src.unsaved && !shared {
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
@@ -490,7 +562,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
}
} else {
let mut k = 1;
- while dir.join(&target).exists() && !(exists_rel && target == src.path) {
+ while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
@@ -613,6 +685,28 @@ mod save_tests {
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
+
+ #[test]
+ fn save_gives_sources_that_share_a_path_their_own_files() {
+ let mut s = Session::default();
+ let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
+ let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
+ add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
+ add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
+ let mut e = Engine::new(s);
+ let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("Shared.scraft");
+ save_session(&mut e, &path.to_string_lossy()).unwrap();
+ let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
+ assert_eq!(paths.len(), 2);
+ assert_ne!(paths[0], paths[1], "{paths:?}");
+ let a = std::fs::read(dir.join(&paths[0])).unwrap();
+ let b = std::fs::read(dir.join(&paths[1])).unwrap();
+ assert_ne!(a, b);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
#[cfg(test)]
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 3662cde..50d82e4 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -53,6 +53,8 @@ pub type Result = std::result::Result;
/// Requests for the transport/audio engine, drained by the host app each frame.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum TransportRequest {
+ /// Stop the old session's transport without applying stop-time edits to the new document.
+ ResetSession,
Play,
Stop,
TogglePlay,
@@ -158,6 +160,9 @@ impl Engine {
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
+ self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
+ self.transport_requests.clear();
+ self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.undo.clear();
self.redo.clear();
diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/session_transport.rs
new file mode 100644
index 0000000..73048e1
--- /dev/null
+++ b/crates/engine/tests/session_transport.rs
@@ -0,0 +1,30 @@
+use serde_json::json;
+use soundcraft_engine::{Engine, TransportRequest, TransportStatus};
+use soundcraft_model::Session;
+use soundcraft_time::Range;
+
+#[test]
+fn replacing_session_resets_status_and_discards_old_transport_requests() {
+ let mut engine = Engine::default();
+ engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 };
+ engine.execute("transport.play", &json!({"from": 96_000})).unwrap();
+ engine.execute("transport.record", &json!({})).unwrap();
+ let mut session = Session::default();
+ session.edit.selection = Range::point(12_000);
+ engine.replace_session(session);
+ assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() });
+ assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]);
+ assert!(!engine.is_dirty());
+}
+
+#[test]
+fn failed_new_session_keeps_the_current_transport() {
+ let mut engine = Engine::default();
+ engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() };
+ engine.execute("transport.play", &json!({})).unwrap();
+ let status = engine.transport;
+ let requests = engine.transport_requests.clone();
+ assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err());
+ assert_eq!(engine.transport, status);
+ assert_eq!(engine.transport_requests, requests);
+}
diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs
index 345d3e8..36c18a0 100644
--- a/crates/midi/src/smf.rs
+++ b/crates/midi/src/smf.rs
@@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
match kind {
0x2F => break, // End of Track
- 0x03 => {
- if name.is_none() {
- name = Some(String::from_utf8_lossy(data).into_owned());
- }
+ 0x03 if name.is_none() => {
+ name = Some(String::from_utf8_lossy(data).into_owned());
}
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
0x51 => {
diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs
index 5b89e34..f1861d8 100644
--- a/crates/midi/tests/smf.rs
+++ b/crates/midi/tests/smf.rs
@@ -91,6 +91,24 @@ fn round_trip() {
assert_eq!(write_smf(&again).unwrap(), bytes);
}
+#[test]
+fn first_track_name_wins_and_ignored_meta_preserves_following_notes() {
+ let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat();
+ for first in [&b"First"[..], &b""[..], &[0xFF][..]] {
+ let body = [
+ meta(0x03, first),
+ meta(0x03, b"Second"),
+ meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored.
+ vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0],
+ ]
+ .concat();
+ let smf = read_smf(&file(0, 960, &[body])).unwrap();
+ assert_eq!(smf.tracks.len(), 1);
+ assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first));
+ assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]);
+ }
+}
+
#[test]
fn write_rejects_bad_values() {
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs
index 70cdd1c..79c2fc8 100644
--- a/crates/mix/src/lib.rs
+++ b/crates/mix/src/lib.rs
@@ -293,6 +293,11 @@ pub struct MixEngine {
order: Vec,
/// Current total compensation delay (samples) of the mix.
pub latency: usize,
+ /// Delay already applied to audio written onto busses: the slowest track insert.
+ /// A bus render drops this so the bus lines up with the timeline. The extra delay
+ /// that lines direct outputs up with aux returns stays in `latency` and is removed
+ /// only from the main mix.
+ track_latency: usize,
/// Live input for the next block (planar), set by the audio host for input monitoring.
pub input: Vec>,
/// Transport stopped: only monitored inputs sound (no clips).
@@ -308,6 +313,8 @@ pub struct MixEngine {
adopt: HashMap<(TrackId, usize), PreparedInstance>,
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
retired: Vec>,
+ /// Ownership workspace reused for parallel strip processing.
+ work: Vec<(usize, Strip)>,
}
/// What `sync` needs to fill a slot.
@@ -363,6 +370,7 @@ impl MixEngine {
synced: (0, 0, 0),
order: Vec::new(),
latency: 0,
+ track_latency: 0,
input: Vec::new(),
monitor_only: false,
recording: false,
@@ -370,6 +378,7 @@ impl MixEngine {
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
+ work: Vec::new(),
}
}
@@ -568,16 +577,17 @@ impl MixEngine {
}
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
let order = std::mem::take(&mut self.order);
- let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
+ let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
- let mut work: Vec<(&Track, Strip)> = order
- .iter()
- .filter_map(|&i| s.tracks.get(i))
- .filter(live)
- .filter(|t| !is_aux(t))
- .filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st)))
- .collect();
+ let mut work = std::mem::take(&mut self.work);
+ work.clear();
+ for &i in &order {
+ let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
+ if let Some(st) = self.strips.remove(&t.id) {
+ work.push((i, st));
+ }
+ }
for (_, st) in work.iter_mut() {
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -585,16 +595,30 @@ impl MixEngine {
}
let busses = std::mem::take(&mut self.busses);
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
- run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo));
+ run_strips(&mut work, |(i, st)| {
+ if let Some(t) = s.tracks.get(*i) {
+ process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
+ }
+ });
self.busses = busses;
- // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait
- // for the slowest aux return (aux latencies are from the previous block; they are stable).
+ // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
+ // for the slowest aux return. Aux latency has to be read before those outputs are routed,
+ // or the first block (and a one-block bounce probe) still sees zero.
let pdc = s.edit.delay_compensation;
+ if pdc {
+ for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
+ if let Some(st) = self.strips.get_mut(&t.id) {
+ note_insert_latency(t, st, pos);
+ }
+ }
+ }
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
let lmax_a =
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
+ self.track_latency = lmax_t;
self.latency = lmax_t + lmax_a;
- for (t, st) in work.iter_mut() {
+ for (i, st) in work.iter_mut() {
+ let Some(t) = s.tracks.get(*i) else { continue };
let ch = st.buf.len();
st.align.set(lmax_t.saturating_sub(st.latency), ch);
st.align.process(&mut st.buf, frames);
@@ -604,15 +628,20 @@ impl MixEngine {
}
st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch);
}
- for (t, st) in work.iter_mut() {
- self.route_strip(t, st, pos, frames);
+ for (i, st) in work.iter_mut() {
+ if let Some(t) = s.tracks.get(*i) {
+ self.route_strip(t, st, pos, frames);
+ }
}
- for (t, st) in work {
- self.strips.insert(t.id, st);
+ for (i, st) in work.drain(..) {
+ if let Some(t) = s.tracks.get(i) {
+ self.strips.insert(t.id, st);
+ }
}
+ self.work = work;
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 2: auxes in dependency order (they read busses).
- for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) {
+ for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
let Some(mut st) = self.strips.remove(&t.id) else { continue };
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -663,7 +692,7 @@ impl MixEngine {
// through any track, send or insert. Only the playback engine sets `metronome`.
// Not while monitoring only (transport stopped): the position does not advance there, so the
// start of the same beat would repeat every block as a buzz.
- if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) {
+ if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) {
click::render(s, pos, frames, &mut self.main);
}
// Output: min(out.len(), main channels) channels; any further output channels are silent.
@@ -679,6 +708,24 @@ impl MixEngine {
self.main_meter.measure(&self.main, frames, 0.0);
}
+ /// Copy one rendered block of `bus` into `out`. A missing bus stays silent.
+ fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec]) {
+ for o in out.iter_mut() {
+ for x in o.iter_mut().take(frames) {
+ *x = 0.0;
+ }
+ }
+ let Some(src) = self.busses.get(&bus) else { return };
+ for (o, c) in out.iter_mut().zip(src.buf.iter()) {
+ for i in 0..frames {
+ let v = c.get(i).copied().unwrap_or(0.0);
+ if let Some(d) = o.get_mut(i) {
+ *d = if v.is_finite() { v } else { 0.0 };
+ }
+ }
+ }
+ }
+
/// Send, meter and pan a processed strip into the busses / main mix.
fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) {
self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr);
@@ -876,8 +923,11 @@ fn process_strip(
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
}
+ // Endpoints are the first and last sample of this block. The next block starts on its own
+ // first sample, so a breakpoint there does not ramp the block before it.
+ let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let mut v0 = volume_db_at(t, pos);
- let mut v1 = volume_db_at(t, pos + frames as i64);
+ let mut v1 = volume_db_at(t, last);
// Trim automation (an offset on top of the volume curve).
if let Some(l) = t
.automation
@@ -886,14 +936,14 @@ fn process_strip(
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
- v1 += l.value_at(pos + frames as i64, 0.0);
+ v1 += l.value_at(last, 0.0);
}
// VCA master: its fader offsets the member's, and its mute mutes the member.
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
v0 += volume_db_at(vca, pos);
- v1 += volume_db_at(vca, pos + frames as i64);
+ v1 += volume_db_at(vca, last);
if vca.mixer.mute {
strip.muted = true;
}
@@ -902,7 +952,7 @@ fn process_strip(
for c in strip.buf.iter_mut() {
for i in 0..frames {
if let Some(x) = c.get_mut(i) {
- *x *= lerp_gain(g0, g1, i, frames);
+ *x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
}
}
}
@@ -1202,10 +1252,12 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
while i < i1 {
let seg_end = (i + STEP).min(i1);
let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start);
- let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start);
+ // The last sample of this step, not the first sample of the next one, so a breakpoint
+ // on the boundary does not ramp the audio before it.
+ let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start);
let g0 = clip.gain_at(rel0);
let g1 = clip.gain_at(rel1.min(clip.length));
- let n = (seg_end - i) as f32;
+ let n = (seg_end - i).saturating_sub(1).max(1) as f32;
for k in i..seg_end {
let rel = pos.saturating_add(k as i64).saturating_sub(clip.start);
let v = if (stretch - 1.0).abs() < 1e-9 {
@@ -1278,6 +1330,20 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
+/// Record insert latency without processing audio, so this block can compensate for it.
+fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
+ let mut latency = 0usize;
+ for (i, ins) in t.mixer.inserts.iter().enumerate() {
+ let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
+ if !ins.active || ins.bypass {
+ continue;
+ }
+ apply_params(slot, ins, t, i, pos);
+ latency = latency.saturating_add(slot.plugin.latency());
+ }
+ strip.latency = latency;
+}
+
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
let slot_u8 = u8::try_from(i).unwrap_or(0);
let automated = t.mixer.automation_mode.reads()
@@ -1640,6 +1706,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> {
out
}
+/// Offline render of one bus over `range` (planar, the bus format's channel count).
+/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline.
+/// An unknown bus is silent.
+pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> {
+ let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
+ if nch == 0 {
+ return Vec::new();
+ }
+ let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
+ let latency = {
+ let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
+ let b = probe.max_block();
+ probe.render(s, range.start, b.min(len.max(1)), &mut []);
+ probe.track_latency
+ };
+ let total = len.saturating_add(latency);
+ let mut out = vec![vec![0.0f32; total]; nch];
+ let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
+ let b = eng.max_block();
+ let mut tmp = vec![vec![0.0f32; b]; nch];
+ let mut done = 0usize;
+ while done < total {
+ let n = (total - done).min(b);
+ eng.render(s, range.start + done as i64, n, &mut []);
+ eng.copy_bus(bus, n, &mut tmp);
+ for (o, t) in out.iter_mut().zip(tmp.iter()) {
+ if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
+ d.copy_from_slice(sr);
+ }
+ }
+ done += n;
+ }
+ if latency > 0 {
+ for c in &mut out {
+ c.drain(..latency.min(c.len()));
+ }
+ }
+ out
+}
+
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> {
@@ -1879,6 +1985,20 @@ mod tests {
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
}
+ #[test]
+ fn render_bus_is_the_bus_not_the_main_mix() {
+ let (mut s, t) = session_with_dc(0.5, 4800);
+ let stem = s.add_bus("Stem", ChannelFormat::Stereo);
+ let empty = s.add_bus("Empty", ChannelFormat::Stereo);
+ s.track_mut(t).unwrap().mixer.output = Route::Bus(stem);
+ let main = render_range(&s, Range::new(0, 4800), 512);
+ let bus = render_bus(&s, stem, Range::new(0, 4800), 512);
+ let silent = render_bus(&s, empty, Range::new(0, 4800), 512);
+ assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]);
+ assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]);
+ assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6)));
+ }
+
#[test]
fn volume_automation_ramps() {
let (mut s, t) = session_with_dc(0.5, 48_000);
@@ -2057,6 +2177,53 @@ mod tests {
assert_ne!(argmax(&out[0]), argmax(&out[1]));
}
+ fn impulse_at(ch: &[f32]) -> Vec {
+ ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
+ }
+
+ /// An aux insert's lookahead is part of the mix latency on the first block, not the block after.
+ #[test]
+ fn delay_compensation_aligns_an_aux_insert() {
+ let mut s = Session::default();
+ let n = 8192;
+ let mut left = vec![0.0f32; n];
+ for at in [0usize, 2048, 8000] {
+ left[at] = 0.25;
+ }
+ s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] })));
+ let bus = s.add_bus("Test Bus", ChannelFormat::Stereo);
+ let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
+ let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
+ let cid = s.new_clip_id();
+ s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64));
+ s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
+ s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
+ let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024);
+ let control = render(&s, 0, n as i64);
+ assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]);
+ assert_eq!(impulse_at(&control[1]), impulse_at(&control[0]));
+
+ let mut comp = Insert::new("compressor");
+ comp.params.insert("lookahead".into(), 5.0);
+ comp.params.insert("ratio".into(), 1.0);
+ s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone());
+ let on_source = render(&s, 0, n as i64);
+ assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0]));
+
+ s.track_mut(source).unwrap().mixer.inserts[0] = None;
+ s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp);
+ let on_aux = render(&s, 0, n as i64);
+ assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce");
+ assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0]));
+ assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]);
+ let partial = render(&s, 2048, 3072);
+ assert_eq!(impulse_at(&partial[0]), vec![0]);
+
+ s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0);
+ let zero = render(&s, 0, n as i64);
+ assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
+ }
+
#[test]
fn input_monitoring_replaces_clips() {
let (mut s, t) = session_with_dc(0.5, 4800);
@@ -2085,6 +2252,21 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
+ #[test]
+ fn strip_workspace_keeps_its_allocation_between_blocks() {
+ let mut s = Session::default();
+ for _ in 0..6 {
+ s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
+ }
+ let mut eng = MixEngine::new(48_000.0, 512);
+ let mut out = vec![vec![0.0; 512]; 2];
+ eng.render(&s, 0, 512, &mut out);
+ let capacity = eng.work.capacity();
+ assert!(capacity >= 6);
+ eng.render(&s, 512, 512, &mut out);
+ assert_eq!(eng.work.capacity(), capacity);
+ }
+
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs
index eb1db9b..b24b251 100644
--- a/crates/playback/src/lib.rs
+++ b/crates/playback/src/lib.rs
@@ -86,7 +86,6 @@ struct AudioState {
playing: bool,
end: Option,
looped: Option,
- block: Vec>,
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
pending: Vec>,
/// Stereo fold-down gains per main channel (used when the device has fewer channels).
@@ -122,7 +121,6 @@ impl AudioState {
playing: false,
end: None,
looped: None,
- block: vec![vec![0.0; BLOCK]; nch],
pending: vec![vec![0.0; BLOCK]; nch],
pending_len: 0,
read: 0.0,
@@ -151,8 +149,7 @@ impl AudioState {
self.mix.adopt(instances);
// The main format changed (a document edit, not steady state): resize.
let nch = main_channels(&s);
- if self.block.len() != nch {
- self.block = vec![vec![0.0; BLOCK]; nch];
+ if self.pending.len() != nch {
self.pending = vec![vec![0.0; BLOCK]; nch];
self.pending_len = 0;
self.read = 0.0;
@@ -171,6 +168,8 @@ impl AudioState {
}
Cmd::Stop => {
self.playing = false;
+ self.end = None;
+ self.looped = None;
self.mix.reset();
}
Cmd::Locate(at) => {
@@ -197,8 +196,8 @@ impl AudioState {
}
/// Render the next block at the session rate into `pending`.
- fn render_next(&mut self) {
- let mut n = BLOCK;
+ fn render_next(&mut self, quantum: usize) {
+ let mut n = quantum.clamp(1, BLOCK);
if let Some(l) = self.looped
&& self.pos >= l.end
{
@@ -209,6 +208,7 @@ impl AudioState {
} else if let Some(e) = self.end {
if self.pos >= e {
self.playing = false;
+ self.end = None;
self.shared.playing.store(false, Ordering::Relaxed);
n = 0;
} else {
@@ -231,19 +231,9 @@ impl AudioState {
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
- self.mix.render(&self.session, self.pos, n, &mut self.block);
+ self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
- for (d, s) in self.pending.iter_mut().zip(self.block.iter()) {
- if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) {
- dd.copy_from_slice(ss);
- }
- }
self.pending_len = n;
- if !self.playing {
- return;
- }
- self.pos += n as i64;
- self.shared.position.store(self.pos, Ordering::Relaxed);
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
@@ -255,6 +245,10 @@ impl AudioState {
}
}
}
+ if self.playing {
+ self.pos += n as i64;
+ self.shared.position.store(self.pos, Ordering::Relaxed);
+ }
}
/// Fill an interleaved device buffer.
@@ -272,13 +266,15 @@ impl AudioState {
}
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
+ let callback_frames = out.len() / channels;
+ let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
for frame in out.chunks_mut(channels) {
while self.read >= self.pending_len as f64 {
self.read -= self.pending_len as f64;
if !self.playing && !monitoring {
break;
}
- self.render_next();
+ self.render_next(quantum);
if self.pending_len == 0 {
break;
}
@@ -622,6 +618,11 @@ impl Player {
self.shared.speed.store(v, Ordering::Relaxed);
}
+ /// Playback rate last requested, 1.0 at full speed.
+ pub fn speed(&self) -> f32 {
+ self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0
+ }
+
pub fn position(&self) -> Samples {
self.shared.position.load(Ordering::Relaxed)
}
@@ -633,6 +634,21 @@ impl Player {
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
+
+ /// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
+ /// no allocation, unlike cloning a new snapshot every video frame.
+ pub fn copy_meters(&self, out: &mut MeterSnapshot) {
+ let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
+ copy_meter_snapshot(&source, out);
+ }
+}
+
+fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
+ out.tracks.retain(|id, _| source.tracks.contains_key(id));
+ for (id, meter) in &source.tracks {
+ out.tracks.entry(*id).or_default().copy_from(meter);
+ }
+ out.main.copy_from(&source.main);
}
impl Side {
@@ -730,6 +746,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre
mod tests {
use super::*;
+ #[test]
+ fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
+ let keep = TrackId(1);
+ let stale = TrackId(2);
+ let mut source = MeterSnapshot::default();
+ source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
+ source.main.peaks = vec![0.75, 0.5];
+ let mut out = MeterSnapshot::default();
+ out.tracks.insert(stale, StripMeter::default());
+ copy_meter_snapshot(&source, &mut out);
+ assert_eq!(out.tracks.len(), 1);
+ assert_eq!(out.tracks[&keep], source.tracks[&keep]);
+ assert_eq!(out.main, source.main);
+ }
+
#[test]
fn audio_state_plays_and_stops_at_end() {
let s = Arc::new(soundcraft_model::Session::default());
@@ -748,6 +779,23 @@ mod tests {
assert!(!shared.playing.load(Ordering::Relaxed));
}
+ #[test]
+ fn short_device_callbacks_render_short_mix_blocks() {
+ let s = Arc::new(soundcraft_model::Session::default());
+ let shared = Arc::new(Shared {
+ position: AtomicI64::new(0),
+ playing: AtomicBool::new(false),
+ speed: AtomicU32::new(1000),
+ meters: Mutex::new(MeterSnapshot::default()),
+ });
+ let (tx, rx) = channel();
+ let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
+ tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
+ let mut out = vec![0.0f32; 2 * 64];
+ st.fill(&mut out, 2);
+ assert_eq!(shared.position.load(Ordering::Relaxed), 64);
+ }
+
#[test]
fn surround_main_maps_to_device_channels_or_folds_down() {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
@@ -896,4 +944,30 @@ mod tests {
assert!(p <= 1000, "position {p} should stay inside the loop");
assert!(shared.playing.load(Ordering::Relaxed));
}
+
+ #[test]
+ fn armed_tracks_are_heard_and_metered_after_playback_stops() {
+ use soundcraft_model::{ChannelFormat, TrackKind};
+ let mut s = soundcraft_model::Session::default();
+ let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
+ s.track_mut(t).unwrap().mixer.record_arm = true;
+ let shared = Arc::new(Shared {
+ position: AtomicI64::new(0),
+ playing: AtomicBool::new(false),
+ speed: AtomicU32::new(1000),
+ meters: Mutex::new(MeterSnapshot::default()),
+ });
+ let (tx, rx) = channel();
+ let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
+ let ring = record::InputRing::new(1, 48_000);
+ tx.send(Cmd::Input(Arc::clone(&ring))).unwrap();
+ tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
+ let mut out = vec![0.0f32; 4096];
+ st.fill(&mut out, 2);
+ assert!(!shared.playing.load(Ordering::Relaxed));
+ ring.push(&[0.5; 4096]);
+ st.fill(&mut out, 2);
+ assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard");
+ assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered");
+ }
}
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index 0439b8b..66158bb 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport
version.workspace = true
edition.workspace = true
license.workspace = true
-rust-version.workspace = true
+rust-version = "1.95"
repository.workspace = true
[dependencies]
+craft-ui.workspace = true
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-midi.workspace = true
@@ -21,7 +22,6 @@ soundcraft-engine.workspace = true
soundcraft-playback.workspace = true
soundcraft-video.workspace = true
egui.workspace = true
-dark-light.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs
index acb1d5a..073b823 100644
--- a/crates/ui-egui/examples/ui_shot.rs
+++ b/crates/ui-egui/examples/ui_shot.rs
@@ -1,9 +1,10 @@
//! Render the real UI offscreen (no window, no focus stealing) and save PNGs.
//!
-//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]`
+//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]`
//!
//! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}`
-//! or `{"steps": n}`. The demo session is loaded unless `--blank`.
+//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the
+//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`.
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -11,11 +12,29 @@ use std::sync::mpsc;
static READY: AtomicBool = AtomicBool::new(false);
+fn system_theme(value: &str) -> Option> {
+ match value {
+ "light" => Some(Some(egui::Theme::Light)),
+ "dark" => Some(Some(egui::Theme::Dark)),
+ "none" => Some(None),
+ _ => None,
+ }
+}
+
fn main() {
let args: Vec = std::env::args().skip(1).collect();
let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into());
let script = args.get(1).filter(|a| !a.starts_with("--")).cloned();
let blank = args.iter().any(|a| a == "--blank");
+ let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") {
+ let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else {
+ eprintln!("--system-theme requires light, dark or none");
+ return;
+ };
+ appearance
+ } else {
+ None
+ };
let (w, h) = args
.iter()
.position(|a| a == "--size")
@@ -39,6 +58,7 @@ fn main() {
app,
);
harness.input_mut().max_texture_side = Some(8192);
+ harness.input_mut().system_theme = appearance;
READY.store(true, Ordering::Relaxed);
let step = |h: &mut egui_kittest::Harness| {
let mut raw = std::mem::take(h.input_mut());
@@ -63,6 +83,15 @@ fn main() {
eprintln!("bad line: {line}");
continue;
};
+ if let Some(value) = v.get("system_theme") {
+ let Some(appearance) = value.as_str().and_then(system_theme) else {
+ eprintln!("system_theme requires light, dark or none: {line}");
+ continue;
+ };
+ harness.input_mut().system_theme = appearance;
+ step(&mut harness);
+ continue;
+ }
if let Some(p) = v.get("shot").and_then(|x| x.as_str()) {
for _ in 0..3 {
step(&mut harness);
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
new file mode 100644
index 0000000..f2668fa
--- /dev/null
+++ b/crates/ui-egui/src/docking.rs
@@ -0,0 +1,475 @@
+//! Application panel identities and persistence around the shared docking renderer.
+//! Document contents remain owned by their existing view implementations.
+
+use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
+use craft_ui::layout::{SplitAxis, SplitSize};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Panel {
+ Canvas,
+ Tracks,
+ Clips,
+ Midi,
+ Memory,
+ History,
+}
+
+impl Panel {
+ pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History];
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Canvas => "canvas",
+ Self::Tracks => "tracks",
+ Self::Clips => "clips",
+ Self::Midi => "midi",
+ Self::Memory => "memory",
+ Self::History => "history",
+ }
+ }
+ fn label(self) -> &'static str {
+ match self {
+ Self::Canvas => "Session",
+ Self::Tracks => "Tracks and Groups",
+ Self::Clips => "Clips",
+ Self::Midi => "MIDI Editor",
+ Self::Memory => "Memory Locations",
+ Self::History => "Undo History",
+ }
+ }
+ fn default_side(self) -> Zone {
+ match self {
+ Self::Canvas => Zone::Right,
+ Self::Tracks => Zone::Left,
+ Self::Clips => Zone::Right,
+ Self::Midi => Zone::Bottom,
+ Self::Memory => Zone::Bottom,
+ Self::History => Zone::Right,
+ }
+ }
+ fn parse(id: &str) -> Result {
+ Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
+ }
+}
+
+const MIDI_TAB_HEIGHT: f32 = 26.0;
+
+fn permissions(panel: &Panel) -> Permissions {
+ if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
+}
+
+fn tabs(panel: Panel) -> Node {
+ Node::Tabs { panels: vec![panel], active: 0 }
+}
+
+fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node {
+ Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Workspace {
+ pub layout: Layout,
+ /// Closed panels retain their most recent group, side, size or floating rectangle.
+ pub hidden: Vec<(Panel, Location)>,
+}
+
+impl Default for Workspace {
+ fn default() -> Self {
+ Self {
+ layout: Layout {
+ root: Some(split(
+ SplitAxis::Vertical,
+ SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT),
+ split(
+ SplitAxis::Horizontal,
+ SplitSize::FixedFirst(168.0),
+ tabs(Panel::Tracks),
+ split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)),
+ ),
+ tabs(Panel::Midi),
+ )),
+ floating: vec![
+ craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] },
+ craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] },
+ ],
+ },
+ hidden: Vec::new(),
+ }
+ }
+}
+
+impl Workspace {
+ pub fn validate(&self) -> Result<(), String> {
+ self.layout.validate().map_err(|error| error.to_string())?;
+ if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
+ return Err("The panel layout must contain its document view and bounded hidden panels".into());
+ }
+ // Saved settings cannot turn the protected document view into a floating or closable tab.
+ if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
+ return Err("The document view cannot float".into());
+ }
+ let mut nodes: Vec<_> = self.layout.root.iter().collect();
+ while let Some(node) = nodes.pop() {
+ match node {
+ Node::Split { first, second, .. } => {
+ nodes.push(first);
+ nodes.push(second);
+ }
+ Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
+ return Err("The document view cannot share a tab group".into());
+ }
+ Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
+ return Err("The document view cannot be collapsed".into());
+ }
+ _ => {}
+ }
+ }
+ let mut seen = std::collections::HashSet::new();
+ for (panel, location) in &self.hidden {
+ if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
+ return Err("A hidden panel has an invalid or duplicate identity".into());
+ }
+ if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
+ return Err("A hidden panel cannot restore over the document view".into());
+ }
+ let mut probe = self.layout.clone();
+ if let Err(error) = probe.restore(*panel, location)
+ && error != craft_ui::docking::DockError::MissingTarget
+ {
+ return Err(error.to_string());
+ }
+ }
+ Ok(())
+ }
+
+ pub fn validate_mix(&self) -> Result<(), String> {
+ self.validate()?;
+ if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) {
+ return Err("Clips and MIDI panels belong to the Edit window".into());
+ }
+ Ok(())
+ }
+
+ pub fn apply(&mut self, action: Action) -> Result<(), String> {
+ self.validate()?;
+ let mut next = self.clone();
+ if let Action::Close { panel } = &action {
+ let location = next.layout.location(panel).map_err(|error| error.to_string())?;
+ next.hidden.retain(|(id, _)| id != panel);
+ next.hidden.push((*panel, location));
+ } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
+ next.hidden.retain(|(id, _)| id != panel);
+ }
+ next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
+ next.validate()?;
+ *self = next;
+ Ok(())
+ }
+
+ pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
+ if self.layout.contains(&panel) == visible {
+ return Ok(());
+ }
+ if !visible {
+ return self.apply(Action::Close { panel });
+ }
+ self.validate()?;
+ let mut next = self.clone();
+ let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
+ next.hidden.retain(|(id, _)| *id != panel);
+ if let Some(saved) = &mut location
+ && saved.floating.is_some()
+ && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
+ {
+ // The other members of a floating group may have been closed too.
+ saved.anchor = None;
+ saved.placement = Placement::Tab { before: None };
+ }
+ let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
+ if !restored {
+ next.layout
+ .apply_with_permissions(
+ Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
+ permissions,
+ )
+ .map_err(|error| error.to_string())?;
+ }
+ next.validate()?;
+ *self = next;
+ Ok(())
+ }
+
+ fn command(&mut self, params: &Value) -> Result<(), String> {
+ self.validate()?;
+ if let Some(action) = params.get("action") {
+ let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
+ return self.apply(action);
+ }
+ let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
+ if operation == "resizeSplit" {
+ let path: Vec =
+ serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
+ let size: SplitSize =
+ serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
+ return self.apply(Action::ResizeSplit { path, size });
+ }
+ let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
+ let action = match operation {
+ "open" => return self.set_visible(panel, true),
+ "close" => Action::Close { panel },
+ "activate" => Action::Activate { panel },
+ "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
+ "resizeStack" => {
+ let value = params.get("height").ok_or("height is required")?;
+ let height = if value.is_null() {
+ None
+ } else {
+ Some(
+ value
+ .as_f64()
+ .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
+ .ok_or("height must be finite and bounded")? as f32,
+ )
+ };
+ Action::ResizeStack { panel, height }
+ }
+ "float" | "moveFloating" => {
+ let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
+ let mut values = [0.0f32; 4];
+ for (output, value) in values.iter_mut().zip(rect) {
+ *output = value
+ .as_f64()
+ .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
+ .ok_or("rect values must be finite and bounded")? as f32;
+ }
+ if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
+ }
+ "move" => {
+ let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
+ let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
+ "center" => Placement::Split(Zone::Center),
+ "left" => Placement::Split(Zone::Left),
+ "right" => Placement::Split(Zone::Right),
+ "top" => Placement::Split(Zone::Top),
+ "bottom" => Placement::Split(Zone::Bottom),
+ _ => return Err("zone must be center, left, right, top or bottom".into()),
+ };
+ let placement = if let Some(before) = params.get("before") {
+ if !matches!(placement, Placement::Split(Zone::Center)) {
+ return Err("before only applies to a center move".into());
+ }
+ Placement::Tab {
+ before: if before.is_null() {
+ None
+ } else {
+ Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
+ },
+ }
+ } else {
+ placement
+ };
+ Action::Move { panel, anchor, placement }
+ }
+ _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
+ };
+ self.apply(action)
+ }
+}
+
+/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit.
+pub fn mix_default() -> Workspace {
+ let mut workspace = Workspace::default();
+ let _ = workspace.set_visible(Panel::Clips, false);
+ let _ = workspace.set_visible(Panel::Midi, false);
+ workspace
+}
+
+fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> {
+ workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?;
+ workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?;
+ workspace.set_visible(Panel::History, app.ui.show_undo_history)?;
+ if edit {
+ workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?;
+ workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?;
+ }
+ Ok(())
+}
+
+fn publish_visibility(app: &mut crate::SoundApp, workspace: &Workspace, edit: bool) {
+ app.ui.show_tracks_list = workspace.layout.contains(&Panel::Tracks);
+ app.ui.show_memory_locations = workspace.layout.contains(&Panel::Memory);
+ app.ui.show_undo_history = workspace.layout.contains(&Panel::History);
+ if edit {
+ app.ui.show_clip_list = workspace.layout.contains(&Panel::Clips);
+ app.ui.show_midi_editor = workspace.layout.contains(&Panel::Midi);
+ }
+}
+
+fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) {
+ if edit {
+ app.ui.docking = workspace;
+ } else {
+ app.ui.mix_docking = workspace;
+ }
+}
+
+pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result {
+ let edit = app.ui.window == crate::MainWindow::Edit;
+ let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
+ let result = command_for(app, reset, params, edit)?;
+ let next = if edit { &app.ui.docking } else { &app.ui.mix_docking };
+ cancel_replaced_workspace(app, &previous, next, edit);
+ Ok(result)
+}
+
+fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) {
+ if (previous.layout != next.layout || previous.hidden != next.hidden)
+ && let Some(ctx) = &app.docking_context
+ {
+ craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit)));
+ }
+}
+
+/// Restoring complete preferences/configurations uses the same ownership boundary as commands.
+pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState) {
+ cancel_replaced_workspace(app, &app.ui.docking, &next.docking, true);
+ cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false);
+}
+
+fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result {
+ if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) {
+ return Err("Clips and MIDI panels belong to the Edit window".into());
+ }
+ let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
+ if !reset && !edit {
+ workspace.validate_mix()?;
+ }
+ if reset {
+ workspace = if edit { Workspace::default() } else { mix_default() };
+ sync(&mut workspace, app, edit)?;
+ } else {
+ sync(&mut workspace, app, edit)?;
+ workspace.command(params)?;
+ if !edit {
+ workspace.validate_mix()?;
+ }
+ }
+ publish_visibility(app, &workspace, edit);
+ let result = json!({"docking": workspace});
+ store(app, workspace, edit);
+ Ok(result)
+}
+
+/// Existing Arrange commands reposition shared floating panels along with the legacy windows.
+pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> {
+ let edit = app.ui.window == crate::MainWindow::Edit;
+ let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() };
+ if edit {
+ workspace.validate()?;
+ } else {
+ workspace.validate_mix()?;
+ }
+ let previous = workspace.clone();
+ let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect();
+ for (index, (panel, mut rect)) in floating.into_iter().enumerate() {
+ rect[0] = 32.0 + index as f32 * 28.0;
+ rect[1] = 80.0 + index as f32 * 28.0;
+ workspace.apply(Action::MoveFloating { panel, rect })?;
+ }
+ cancel_replaced_workspace(app, &previous, &workspace, edit);
+ store(app, workspace, edit);
+ Ok(())
+}
+
+pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) {
+ app.docking_context = Some(ui.ctx().clone());
+ let t = crate::theme::Tokens::current();
+ let edit = window == crate::MainWindow::Edit;
+ let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) };
+ let before_sync = workspace.clone();
+ if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) {
+ app.ui.status = format!("Panel layout could not be restored: {error}");
+ workspace = if edit { Workspace::default() } else { mix_default() };
+ let _ = sync(&mut workspace, app, edit);
+ }
+ cancel_replaced_workspace(app, &before_sync, &workspace, edit);
+ let mut style = DockStyle::from_ui(ui);
+ style.min_pane = 96.0;
+ style.tab_height = MIDI_TAB_HEIGHT;
+ style.background = t.panel_bg;
+ style.tab_background = t.panel_bg2;
+ style.active_background = t.row_selected;
+ style.text = t.text;
+ style.inactive_text = t.text_dim;
+ style.border = egui::Stroke::new(1.0, t.border);
+ style.accent = t.accent;
+ style.font = crate::theme::regular(11.5);
+ let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits(
+ ui,
+ &workspace.layout,
+ &style,
+ |panel| panel.label().into(),
+ permissions,
+ panel_limits,
+ |ui, panel| match panel {
+ Panel::Canvas => {
+ ui.painter().rect_filled(ui.max_rect(), 0.0, t.window_bg);
+ if edit {
+ crate::edit_window::main_area(app, ui);
+ } else {
+ crate::mix_window::content(app, ui);
+ }
+ }
+ Panel::Tracks => crate::panels::tracks_and_groups(app, ui),
+ Panel::Clips => crate::panels::clip_list(app, ui),
+ Panel::Midi => {
+ ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect()));
+ minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui));
+ }
+ Panel::Memory => crate::panels::memory_locations(app, ui),
+ Panel::History => crate::panels::undo_history(app, ui),
+ },
+ );
+ let _ = sync(&mut workspace, app, edit);
+ publish_visibility(app, &workspace, edit);
+ store(app, workspace, edit);
+ for action in output.actions {
+ if let Err(error) = serde_json::to_value(action)
+ .map_err(|error| error.to_string())
+ .and_then(|action| command_for(app, false, &json!({"action": action}), edit))
+ {
+ app.ui.status = format!("Panel move was not applied: {error}");
+ }
+ }
+}
+
+/// Docking may make a specialist view smaller than its fixed tool/ruler geometry. Keep that
+/// geometry scrollable inside its clipped panel instead of producing inverted rectangles.
+pub(crate) fn minimum_body(ui: &mut egui::Ui, minimum: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) {
+ let available = ui.available_size();
+ if available.x >= minimum.x && available.y >= minimum.y {
+ body(ui);
+ return;
+ }
+ let extent = available.max(minimum);
+ egui::ScrollArea::both().id_salt("minimum-body").auto_shrink([false, false]).show(ui, |ui| {
+ let (rect, _) = ui.allocate_exact_size(extent, egui::Sense::hover());
+ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
+ body(&mut child);
+ });
+}
+
+fn panel_limits(panel: &Panel) -> PanelLimits {
+ let (min, max_width) = match panel {
+ Panel::Canvas => (egui::vec2(300.0, 240.0), 1_000_000.0),
+ Panel::Tracks => (egui::vec2(140.0, 120.0), 520.0),
+ Panel::Clips => (egui::vec2(180.0, 120.0), 720.0),
+ Panel::Midi => (egui::vec2(420.0, 260.0), 1_000_000.0),
+ Panel::Memory => (egui::vec2(260.0, 160.0), 1_000_000.0),
+ Panel::History => (egui::vec2(200.0, 160.0), 720.0),
+ };
+ PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
+}
diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs
index 0ea03a2..906ae1e 100644
--- a/crates/ui-egui/src/edit_window.rs
+++ b/crates/ui-egui/src/edit_window.rs
@@ -2,7 +2,7 @@
use crate::theme::{Tokens, bold, clip_colors, regular, rgb};
use crate::widgets::{rec_toggle, selector_box, text_toggle};
-use crate::{Gesture, SoundApp, icons, panels, toolbar};
+use crate::{Gesture, SoundApp, icons, toolbar};
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind};
@@ -66,27 +66,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui));
egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui));
egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui));
- if app.ui.show_midi_editor {
- egui::Panel::bottom("midi_editor")
- .resizable(true)
- .default_size(320.0)
- .min_size(260.0)
- .frame(egui::Frame::NONE.fill(t.panel_bg))
- .show(ui, |ui| crate::midi_editor::show(app, ui));
- }
if app.ui.show_universe {
egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui));
}
- if app.ui.show_tracks_list {
- egui::Panel::left("tracks_list")
- .exact_size(168.0)
- .frame(egui::Frame::NONE.fill(t.panel_bg))
- .show(ui, |ui| panels::tracks_and_groups(app, ui));
- }
- if app.ui.show_clip_list {
- egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui));
- }
- egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui));
+ crate::docking::show(app, ui, crate::MainWindow::Edit);
}
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
@@ -177,7 +160,17 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
);
}
-fn main_area(app: &mut SoundApp, ui: &mut Ui) {
+pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) {
+ let session = app.engine.session();
+ let mut ruler_height_total = session.edit.rulers.iter().map(|ruler| ruler_height(ruler)).sum::();
+ if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") {
+ ruler_height_total += ruler_height("tempo_editor");
+ }
+ let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0);
+ crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui));
+}
+
+fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let full = ui.max_rect();
let rulers: Vec = app.engine.session().edit.rulers.clone();
diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs
index 71e63fa..e269573 100644
--- a/crates/ui-egui/src/extra_windows.rs
+++ b/crates/ui-egui/src/extra_windows.rs
@@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option {
}
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
- apply_customization(app, ctx);
auto_update(app, ctx);
renderer(app, ctx);
ui_customization(app, ctx);
+ apply_customization(app, ctx);
}
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
- if app.extra.applied == Some(want) {
+ // Rescaling mid-drag would move the Interface scale slider out from under the pointer.
+ if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
return;
}
// Only touch the zoom once the user has changed it, so platform scaling stays untouched.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index fc0685d..b324ad3 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -7,6 +7,7 @@
pub mod control;
pub mod credits;
pub mod dialogs;
+pub mod docking;
pub mod edit_window;
pub mod extra_windows;
pub mod fonts;
@@ -47,6 +48,9 @@ pub enum MainWindow {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct UiState {
+ pub docking: docking::Workspace,
+ #[serde(default = "docking::mix_default")]
+ pub mix_docking: docking::Workspace,
pub window: MainWindow,
pub show_tracks_list: bool,
pub show_clip_list: bool,
@@ -102,6 +106,8 @@ pub struct UiState {
impl Default for UiState {
fn default() -> Self {
UiState {
+ docking: docking::Workspace::default(),
+ mix_docking: docking::mix_default(),
window: MainWindow::Edit,
show_tracks_list: true,
show_clip_list: true,
@@ -214,6 +220,8 @@ pub struct SoundApp {
pub dialogs: dialogs::Dialogs,
pub meters: HashMap,
pub main_meter: MeterDisplay,
+ /// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
+ meter_snapshot: soundcraft_playback::MeterSnapshot,
pub gesture: Option,
pub edit_layout: edit_window::EditLayout,
pub midi: midi_editor::MidiEditorState,
@@ -229,6 +237,8 @@ pub struct SoundApp {
fonts_installed: bool,
/// The palette last applied to the context (light?), so visuals are set only on a change.
theme_applied: Option,
+ /// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
+ pub(crate) docking_context: Option,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option, Option)>,
pub quit_requested: bool,
@@ -239,6 +249,7 @@ pub struct SoundApp {
last_write_at: Samples,
pub frame_ms: f32,
last_frame: Option,
+ last_meter_poll: f64,
/// Movies for the Video window and Video track (decoded in the background).
pub video: video_track::VideoPool,
}
@@ -272,6 +283,7 @@ impl SoundApp {
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
+ meter_snapshot: soundcraft_playback::MeterSnapshot::default(),
gesture: None,
edit_layout: edit_window::EditLayout::default(),
midi: midi_editor::MidiEditorState::default(),
@@ -286,6 +298,7 @@ impl SoundApp {
fonts_ready: false,
fonts_installed: false,
theme_applied: None,
+ docking_context: None,
sim: None,
quit_requested: false,
arrange_request: false,
@@ -293,6 +306,7 @@ impl SoundApp {
last_write_at: i64::MIN,
frame_ms: 0.0,
last_frame: None,
+ last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
}
}
@@ -320,6 +334,9 @@ impl SoundApp {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
+ if id == "track.clear_clip_indicators" {
+ self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
+ }
Ok(v)
}
Err(e) => {
@@ -358,6 +375,8 @@ impl SoundApp {
fn stop_play(&mut self) {
if let Some(p) = &self.player {
+ // Half-speed is a separate command. Stop must not leave that rate for the next Play.
+ p.set_speed(1.0);
p.stop();
}
if self.engine.transport.recording {
@@ -503,6 +522,12 @@ impl SoundApp {
best.map(|(_, p)| p)
}
+ /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard.
+ fn meters_live(&self) -> bool {
+ self.engine.transport.playing
+ || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm))
+ }
+
/// Open the input device once (for recording and input monitoring) and feed the player.
fn ensure_input(&mut self) {
if self.recorder.is_some() || self.recorder_failed || self.player.is_none() {
@@ -642,6 +667,25 @@ impl SoundApp {
let reqs: Vec = std::mem::take(&mut self.engine.transport_requests);
for r in reqs {
match r {
+ TransportRequest::ResetSession => {
+ if let Some(p) = &self.player {
+ p.stop();
+ p.set_recording(false);
+ p.set_speed(1.0);
+ p.locate(self.engine.transport.position);
+ }
+ self.sim = None;
+ // The old capture belongs to the replaced document, never to the new one.
+ self.recorder = None;
+ self.recorder_failed = false;
+ self.punch = None;
+ self.gesture = None;
+ self.touched.clear();
+ self.last_write_at = i64::MIN;
+ self.edit_layout.follow_hold = false;
+ self.meters.clear();
+ self.main_meter = MeterDisplay::default();
+ }
TransportRequest::Play => {
if !self.is_playing() {
self.play_from_selection();
@@ -697,7 +741,13 @@ impl SoundApp {
if self.engine.transport.playing {
self.engine.transport.position = p.position();
}
- let snap = p.meters();
+ let now = self.last_frame.unwrap_or_default();
+ let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
+ if now - self.last_meter_poll >= meter_interval {
+ p.copy_meters(&mut self.meter_snapshot);
+ self.last_meter_poll = now;
+ }
+ let snap = &self.meter_snapshot;
for (id, m) in &snap.tracks {
let d = self.meters.entry(*id).or_default();
feed_meter(d, m.peak, m.gain_reduction_db, dt);
@@ -721,7 +771,7 @@ impl SoundApp {
self.stop_play();
}
}
- if !self.engine.transport.playing {
+ if !self.meters_live() {
for d in self.meters.values_mut() {
feed_meter(d, [0.0, 0.0], 0.0, dt);
}
@@ -761,13 +811,14 @@ impl SoundApp {
self.engine.end_merge();
}
}
+ // Stop the old playback before sending a replacement session to the audio thread.
+ self.handle_transport(dt);
if self.engine.revision != self.last_rev {
self.last_rev = self.engine.revision;
if let Some(p) = &self.player {
p.update_session(self.engine.session_arc());
}
}
- self.handle_transport(dt);
self.write_automation(ctx);
self.autosave(now);
if self.recorder.is_none()
@@ -776,7 +827,12 @@ impl SoundApp {
{
self.ensure_input();
}
- if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
+ if self.meters_live() {
+ let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
+ ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
+ } else if self.meters.values().any(|m| m.level[0] > 0.0001) {
+ ctx.request_repaint_after(std::time::Duration::from_millis(80));
+ } else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
control::collect_screenshots(self, ctx);
@@ -818,6 +874,8 @@ impl SoundApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
+ // The integration creates this Ui before logic resolves the current frame's palette.
+ ui.set_style(ctx.global_style());
if !self.fonts_ready {
ctx.request_repaint();
return;
@@ -889,7 +947,74 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
#[cfg(test)]
mod tests {
- use super::automation_step_due;
+ use super::{Services, SoundApp, automation_step_due};
+ use serde_json::json;
+
+ #[test]
+ fn new_session_stops_playback_and_the_old_clock() {
+ for (command, params) in [
+ ("session.new", json!({"name": "New project", "sample_rate": 96_000})),
+ ("session.new", json!({"template": "demo"})),
+ ("session.close", json!({})),
+ ] {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.run("transport.play", json!({"from": 48_000})).unwrap();
+ app.handle_transport(0.25);
+ assert!(app.is_playing());
+ assert!(app.position() > 48_000);
+ app.run(command, params).unwrap();
+ let insertion = app.engine.session().edit.selection.start;
+ app.handle_transport(0.25);
+ assert!(!app.is_playing(), "{command}");
+ assert_eq!(app.position(), insertion, "{command}");
+ assert_eq!(app.engine.transport.position, insertion, "{command}");
+ app.handle_transport(0.25);
+ assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped");
+ app.run("transport.play", json!({})).unwrap();
+ app.handle_transport(0.25);
+ assert!(app.is_playing());
+ assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4);
+ }
+ }
+
+ #[test]
+ fn new_session_cancels_play_queued_before_the_next_frame() {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.run("transport.play", json!({})).unwrap();
+ app.run("session.new", json!({})).unwrap();
+ app.handle_transport(0.25);
+ assert!(!app.is_playing());
+ assert_eq!(app.engine.transport.position, 0);
+ }
+
+ #[test]
+ fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.run("transport.play", json!({"from": 48_000})).unwrap();
+ app.handle_transport(0.25);
+ let mut session = soundcraft_model::Session::default();
+ session.edit.selection = soundcraft_time::Range::new(12_000, 24_000);
+ session.edit.insertion_follows_playback = true;
+ session.edit.scrolling = "after_playback".into();
+ let selection = session.edit.selection;
+ app.engine.replace_session(session);
+ app.handle_transport(0.25);
+ assert!(!app.is_playing());
+ assert_eq!(app.engine.session().edit.selection, selection);
+ assert_eq!(app.position(), selection.start);
+ assert_eq!(app.engine.session().edit.zoom.scroll, 0);
+ assert!(!app.engine.is_dirty());
+ }
+
+ #[test]
+ fn clear_all_clip_indicators_clears_every_meter() {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ let kick = app.engine.session().track_by_name("Kick").unwrap().id;
+ app.meters.entry(kick).or_default().clip = true;
+ app.main_meter.clip = true;
+ app.run("track.clear_clip_indicators", json!({})).unwrap();
+ assert!(!app.meters[&kick].clip && !app.main_meter.clip);
+ }
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
@@ -909,4 +1034,43 @@ mod tests {
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
+
+ #[test]
+ fn ordinary_play_after_stop_is_full_speed() {
+ use serde_json::json;
+ let engine = soundcraft_engine::Engine::default();
+ let player = soundcraft_playback::Player::new(engine.session_arc());
+ let mut app = super::SoundApp::new(engine, Some(player), super::Services::default());
+ app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap();
+ app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap();
+ app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap();
+ let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0);
+ assert!((speed(&app) - 1.0).abs() < 1e-3);
+
+ app.engine.execute("transport.half_speed", &json!({})).unwrap();
+ app.handle_transport(1.0 / 60.0);
+ assert!(app.is_playing());
+ assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback");
+
+ app.engine.execute("transport.stop", &json!({})).unwrap();
+ app.handle_transport(1.0 / 60.0);
+ assert!(!app.is_playing());
+ assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed");
+
+ app.engine.execute("transport.play", &json!({})).unwrap();
+ app.handle_transport(1.0 / 60.0);
+ assert!(app.is_playing());
+ assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed");
+
+ app.engine.execute("transport.stop", &json!({})).unwrap();
+ app.handle_transport(1.0 / 60.0);
+ app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap();
+ app.engine.execute("transport.half_speed", &json!({})).unwrap();
+ app.handle_transport(1.0 / 60.0);
+ assert!((speed(&app) - 0.5).abs() < 1e-3);
+ app.player.as_ref().unwrap().stop();
+ app.handle_transport(1.0 / 60.0);
+ assert!(!app.is_playing());
+ assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
+ }
}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 7f1b9a4..258ee9b 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -7,6 +7,8 @@ use soundcraft_engine::catalog;
/// UI-layer commands: (id, label, catalog path or "", shortcut).
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
+ ("ui.dock", "Arrange Panel", "", None),
+ ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
@@ -74,29 +76,41 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
/// Extra catalog mappings handled by the UI layer.
pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect();
- for sec in [
- "Inserts A-E",
- "Inserts F-J",
- "Sends A-E",
- "Sends F-J",
- "I/O",
- "Track Color",
- "Comments",
- "EQ Curve",
- "Meters and Faders",
- "All",
- "Minimal",
- "Mic Preamps",
- "Instruments",
- "Object",
- ] {
- v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section"));
- }
+ v.extend(mix_view_aliases().iter().copied());
v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process")));
v.extend(MENU_WINDOWS.iter().copied());
v
}
+/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame.
+fn mix_view_aliases() -> &'static [(&'static str, &'static str)] {
+ static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new();
+ ALIASES.get_or_init(|| {
+ [
+ "Inserts A-E",
+ "Inserts F-J",
+ "Sends A-E",
+ "Sends F-J",
+ "I/O",
+ "Track Color",
+ "Comments",
+ "EQ Curve",
+ "Meters and Faders",
+ "All",
+ "Minimal",
+ "Mic Preamps",
+ "Instruments",
+ "Object",
+ ]
+ .into_iter()
+ .map(|sec| {
+ let path: &'static str = Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str());
+ (path, "view.mix_section")
+ })
+ .collect()
+ })
+}
+
/// AudioSuite catalog entries → our processes (functional equivalents, our own plugins).
pub const AUDIOSUITE: &[(&str, &str)] = &[
("AudioSuite > EQ > Channel Strip", "channel_strip"),
@@ -183,6 +197,7 @@ pub fn tree() -> Vec {
for line in catalog::catalog() {
insert_path(&mut roots, line);
}
+ insert_path(&mut roots, "Window > Reset Panel Layout");
for (path, _) in MENU_WINDOWS {
insert_path(&mut roots, path);
}
@@ -266,7 +281,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option {
/// Enabled and checked state shared by all menu presentations.
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
let enabled = match id {
- Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true,
+ Some(i)
+ if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
+ {
+ true
+ }
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
None => false,
};
@@ -391,6 +410,15 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) {
return;
}
+ // These ids only exist to open a dialog from the menu. Programmatic `app.run` refuses them.
+ if id == "ui.new_tracks_dialog" {
+ let _ = app.dialogs.open_for_command(&app.engine, "track.new");
+ return;
+ }
+ if id == "ui.bounce_dialog" {
+ let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
+ return;
+ }
let params = params_for(path, id);
let _ = app.run(id, params);
}
@@ -407,6 +435,9 @@ fn wants_dialog(id: &str) -> bool {
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> {
+ if matches!(id, "ui.dock" | "ui.dock.reset") {
+ return Some(crate::docking::command(app, id == "ui.dock.reset", p));
+ }
if let Some(v) = crate::extra_windows::run(app, id, p) {
return Some(Ok(v));
}
@@ -474,6 +505,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations),
"window.config_new" | "window.config_update" => {
+ if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) {
+ return Some(Err(error));
+ }
let mut snap = app.ui.clone();
snap.configurations.clear();
let v = serde_json::to_value(&snap).unwrap_or_default();
@@ -488,6 +522,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option {
+ if let Err(error) = crate::docking::arrange(app) {
+ app.ui.status = format!("Panel windows could not be arranged: {error}");
+ }
app.arrange_request = true;
json!({})
}
@@ -525,12 +562,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option {
- let _ = app.dialogs.open_for_command(&app.engine, "track.new");
- json!({})
+ return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into()));
}
"ui.bounce_dialog" => {
- let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
- json!({})
+ return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into()));
}
"ui.theme" => {
let Some(mode) = p.get("mode").and_then(Value::as_str) else {
@@ -539,9 +574,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Option(cur) {
Ok(ns) => {
+ if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) {
+ return Some(Err(error));
+ }
+ crate::docking::cancel_replaced_state(app, &ns);
app.ui = ns;
json!({"ui": app.ui})
}
@@ -572,3 +608,35 @@ pub fn parity() -> Value {
let extra = ui_aliases();
catalog::parity_with(&extra)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{Services, SoundApp};
+
+ #[test]
+ fn mix_view_aliases_are_allocated_once() {
+ let first = ui_aliases();
+ let second = ui_aliases();
+ let a = first.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap();
+ let b = second.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap();
+ assert!(std::ptr::eq(a, b));
+ assert_eq!(first.iter().filter(|(path, _)| path.contains("Mix Window Views")).count(), 14);
+ }
+
+ #[test]
+ fn programmatic_dialog_commands_stay_closed_and_the_menu_opens_them() {
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ let err = app.run("ui.new_tracks_dialog", json!({})).unwrap_err();
+ assert!(err.contains("track.new"), "{err}");
+ let err = app.run("ui.bounce_dialog", json!({})).unwrap_err();
+ assert!(err.contains("file.bounce_mix"), "{err}");
+ assert!(app.dialogs.open_name().is_none());
+
+ invoke_menu(&mut app, "ui.new_tracks_dialog", "Track > New...");
+ assert_eq!(app.dialogs.open_name(), Some("new_tracks"));
+ app.dialogs.open = None;
+ invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
+ assert_eq!(app.dialogs.open_name(), Some("bounce"));
+ }
+}
diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs
index 3716c98..a4882d4 100644
--- a/crates/ui-egui/src/midi_editor.rs
+++ b/crates/ui-egui/src/midi_editor.rs
@@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
+ // Painter-only contents do not advance the layout. Claim the resized
+ // space, including the empty state, so the dock keeps its chosen height.
+ ui.take_available_space();
let t = Tokens::current();
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.panel_bg);
diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs
index 20591fe..d977825 100644
--- a/crates/ui-egui/src/mix_window.rs
+++ b/crates/ui-egui/src/mix_window.rs
@@ -1,10 +1,11 @@
//! The Mix window: one channel strip per track.
+use crate::SoundApp;
use crate::theme::{Tokens, bold, regular, rgb};
use crate::widgets::{
- PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle,
+ PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip,
+ surround_panner, text_toggle,
};
-use crate::{SoundApp, panels};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind};
@@ -15,28 +16,19 @@ pub fn strip_width(narrow: bool) -> f32 {
}
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
- let t = Tokens::current();
- if app.ui.show_tracks_list {
- egui::Panel::left("mix_tracks_list")
- .exact_size(168.0)
- .frame(egui::Frame::NONE.fill(t.panel_bg))
- .show(ui, |ui| panels::tracks_and_groups(app, ui));
- }
- egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| {
- let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
- // Per-channel peaks for multichannel meters (read once per frame).
- let snap = app
- .player
- .as_ref()
- .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
- .map(|p| p.meters());
- egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
- ui.horizontal_top(|ui| {
- ui.spacing_mut().item_spacing.x = 1.0;
- for id in ids {
- strip(app, ui, id, snap.as_ref());
- }
- });
+ crate::docking::show(app, ui, crate::MainWindow::Mix);
+}
+
+pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
+ let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
+ // Per-channel peaks for multichannel meters (read once per frame).
+ let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
+ egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
+ ui.horizontal_top(|ui| {
+ ui.spacing_mut().item_spacing.x = 1.0;
+ for id in ids {
+ strip(app, ui, id, snap.as_ref());
+ }
});
});
}
@@ -247,7 +239,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
} else {
t.auto_write
};
- let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col);
+ let full_label = format!("auto {}", mode.label());
+ let label = if mode == soundcraft_model::AutomationMode::TouchLatch {
+ if narrow { "t/l" } else { mode.label() }
+ } else if narrow {
+ mode.label()
+ } else {
+ &full_label
+ };
+ let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label);
egui::Popup::menu(&resp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
@@ -410,12 +410,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let ins = track.instrument.as_ref();
let info = ins.and_then(|i| plugin_info(&i.plugin));
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
- let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
+ let fill = if info.is_some() { t.insert_on } else { t.slot_bg };
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
- Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
+ Stroke::new(1.0, t.slot_border),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text);
diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs
index d298ab3..87c7dde 100644
--- a/crates/ui-egui/src/ops_windows.rs
+++ b/crates/ui-egui/src/ops_windows.rs
@@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_beat_detective = open;
}
+/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end`
+/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`).
+pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) {
+ let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 };
+ let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 };
+ match curve {
+ "constant" => ("event.tempo_constant", json!({"bpm": start_bpm})),
+ "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
+ "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
+ "scale" => ("event.tempo_scale", json!({"factor": end / start})),
+ "stretch" => ("event.tempo_stretch", json!({"factor": start / end})),
+ _ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
+ }
+}
+
fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_tempo_ops;
if !open {
@@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
});
ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm "));
ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm "));
- ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small());
+ ui.label(
+ egui::RichText::new(
+ "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).",
+ )
+ .small(),
+ );
if ui.button(egui::RichText::new("Apply").strong()).clicked() {
- let (id, p) = match app.ops.tempo_curve.as_str() {
- "constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})),
- "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
- "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
- "scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})),
- "stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})),
- _ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
- };
+ let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end);
let r = app.run(id, p);
result_line(ui, &r);
}
@@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
});
app.ui.show_rtp = open;
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn stretch_uses_start_over_end_and_scale_uses_end_over_start() {
+ let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0);
+ assert_eq!(id, "event.tempo_stretch");
+ assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}");
+ let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0);
+ assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
+ let (id, p) = tempo_ops_apply("scale", 120.0, 60.0);
+ assert_eq!(id, "event.tempo_scale");
+ assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}");
+ let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY);
+ assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
+ let (id, p) = tempo_ops_apply("linear", 100.0, 140.0);
+ assert_eq!(id, "event.tempo_linear");
+ assert_eq!(p["end_bpm"], 140.0);
+ }
+}
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 24eadb7..153c983 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -173,9 +173,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
/// Floating windows.
pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
transport_window(app, ctx);
- memory_locations(app, ctx);
big_counter(app, ctx);
- undo_history(app, ctx);
plugin_windows(app, ctx);
about(app, ctx);
session_info(app, ctx);
@@ -282,92 +280,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_big_counter = open;
}
-fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) {
- let mut open = app.ui.show_memory_locations;
- if !open {
- return;
- }
- egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| {
- ui.horizontal(|ui| {
- if ui.button("+ Marker").clicked() {
- let _ = app.run("markers.add", json!({}));
+pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
+ ui.horizontal(|ui| {
+ if ui.button("+ Marker").clicked() {
+ let _ = app.run("markers.add", json!({}));
+ }
+ if ui.button("+ Selection").clicked() {
+ let _ = app.run("markers.add", json!({"kind": "selection"}));
+ }
+ });
+ let rows: Vec<(u32, String, String, String, String)> = {
+ let s = app.engine.session();
+ let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
+ s.markers
+ .iter()
+ .map(|m| {
+ let kind = match m.kind {
+ soundcraft_model::MarkerKind::Marker => "marker",
+ soundcraft_model::MarkerKind::Selection => "selection",
+ soundcraft_model::MarkerKind::None => "none",
+ };
+ let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
+ (m.number, m.name.clone(), f(m.start), len, kind.to_string())
+ })
+ .collect()
+ };
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
+ for h in ["#", "Name", "Location", "Length", "Type", ""] {
+ ui.label(egui::RichText::new(h).strong());
}
- if ui.button("+ Selection").clicked() {
- let _ = app.run("markers.add", json!({"kind": "selection"}));
- }
- });
- let rows: Vec<(u32, String, String, String, String)> = {
- let s = app.engine.session();
- let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
- s.markers
- .iter()
- .map(|m| {
- let kind = match m.kind {
- soundcraft_model::MarkerKind::Marker => "marker",
- soundcraft_model::MarkerKind::Selection => "selection",
- soundcraft_model::MarkerKind::None => "none",
- };
- let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
- (m.number, m.name.clone(), f(m.start), len, kind.to_string())
- })
- .collect()
- };
- egui::ScrollArea::vertical().show(ui, |ui| {
- egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
- for h in ["#", "Name", "Location", "Length", "Type", ""] {
- ui.label(egui::RichText::new(h).strong());
+ ui.end_row();
+ for (n, name, loc, len, kind) in rows {
+ if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
+ let _ = app.run("markers.recall", json!({"number": n}));
+ }
+ let key = egui::Id::new(("mem_name", n));
+ let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
+ let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
+ if r.changed() {
+ ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
+ }
+ if r.lost_focus() && edit != name {
+ let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
+ ui.ctx().memory_mut(|m| m.data.remove::(key));
+ }
+ ui.label(egui::RichText::new(loc).font(mono(11.0)));
+ ui.label(egui::RichText::new(len).font(mono(11.0)));
+ ui.label(kind);
+ if ui.small_button("✕").on_hover_text("Delete").clicked() {
+ let _ = app.run("markers.delete", json!({"number": n}));
}
ui.end_row();
- for (n, name, loc, len, kind) in rows {
- if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
- let _ = app.run("markers.recall", json!({"number": n}));
- }
- let key = egui::Id::new(("mem_name", n));
- let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
- let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
- if r.changed() {
- ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
- }
- if r.lost_focus() && edit != name {
- let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
- ui.ctx().memory_mut(|m| m.data.remove::(key));
- }
- ui.label(egui::RichText::new(loc).font(mono(11.0)));
- ui.label(egui::RichText::new(len).font(mono(11.0)));
- ui.label(kind);
- if ui.small_button("✕").on_hover_text("Delete").clicked() {
- let _ = app.run("markers.delete", json!({"number": n}));
- }
- ui.end_row();
- }
- });
+ }
});
});
- app.ui.show_memory_locations = open;
}
-fn undo_history(app: &mut SoundApp, ctx: &egui::Context) {
- let mut open = app.ui.show_undo_history;
- if !open {
- return;
- }
- egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| {
- let hist = app.engine.undo_history();
- let n = hist.len();
- egui::ScrollArea::vertical().show(ui, |ui| {
- for (i, h) in hist.iter().enumerate() {
- if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() {
- for _ in 0..(n - i - 1) {
- let _ = app.run("edit.undo", json!({}));
- }
+pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) {
+ let hist = app.engine.undo_history();
+ let n = hist.len();
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ for (i, h) in hist.iter().enumerate() {
+ if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() {
+ for _ in 0..(n - i - 1) {
+ let _ = app.run("edit.undo", json!({}));
}
}
- if let Some(r) = app.engine.redo_label() {
- ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
- }
- });
+ }
+ if let Some(r) = app.engine.redo_label() {
+ ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
+ }
});
- app.ui.show_undo_history = open;
}
fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index bffa713..fb978c3 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
+ // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
+ ctx.options_mut(|o| o.zoom_with_keyboard = false);
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
return;
}
@@ -170,6 +172,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run(id, json!({}));
true
}
+ // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key).
+ Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => {
+ let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" };
+ let _ = app.run(id, json!({}));
+ true
+ }
// Commands Keyboard Focus: single-key editing (Edit window only).
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
Key::R | Key::T if plain => {
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
index 113e152..c667e6f 100644
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -1,17 +1,17 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use egui::{Color32, FontFamily, FontId};
-use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
+use std::sync::atomic::{AtomicBool, Ordering};
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
-/// The operating system's appearance, asked once off the UI thread.
-static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN);
-const SYSTEM_UNKNOWN: u8 = 0;
-const SYSTEM_ASKING: u8 = 1;
-const SYSTEM_DARK: u8 = 2;
-const SYSTEM_LIGHT: u8 = 3;
+/// Unit tests that change the process-wide palette must own it until their assertions finish.
+#[cfg(test)]
+pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
+ static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(());
+ PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
+}
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -335,49 +335,15 @@ pub fn is_light() -> bool {
LIGHT_THEME.load(Ordering::Relaxed)
}
-/// Whether `mode` resolves to the Light palette right now. Never blocks.
+/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
match mode {
ThemeMode::Light => true,
ThemeMode::Dark => false,
- ThemeMode::System => system_is_light(ctx),
+ ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
}
}
-/// Forget the cached system appearance so the next System lookup asks again.
-pub fn refresh_system() {
- let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
- let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
-}
-
-/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it
-/// answers this reports dark, and `ctx` is repainted when the answer arrives.
-#[cfg(not(target_arch = "wasm32"))]
-fn system_is_light(ctx: &egui::Context) -> bool {
- if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
- let ctx = ctx.clone();
- let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || {
- let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
- SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
- ctx.request_repaint();
- });
- if spawned.is_err() {
- SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed);
- }
- }
- SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
-}
-
-/// On the web the query is a synchronous `matchMedia` lookup (no threads there).
-#[cfg(target_arch = "wasm32")]
-fn system_is_light(_ctx: &egui::Context) -> bool {
- if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
- let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
- SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
- }
- SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
-}
-
/// Track colours tinted for clip bodies.
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
let [r, g, b] = rgb;
@@ -430,8 +396,13 @@ pub fn apply(ctx: &egui::Context, light: bool) {
v.widgets.inactive.fg_stroke.color = t.text;
v.window_corner_radius = egui::CornerRadius::same(6);
v.menu_corner_radius = egui::CornerRadius::same(4);
- ctx.set_visuals(v);
- ctx.global_style_mut(|s| {
+ // Keep receiving native OS appearance changes even when our palette is manual. On macOS,
+ // pinning egui's theme also pins the window appearance and hides later OS changes.
+ ctx.set_theme(egui::ThemePreference::System);
+ // Both egui style branches must use our resolved palette: egui can select either branch
+ // as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed.
+ ctx.all_styles_mut(|s| {
+ s.visuals = v.clone();
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.interaction.tooltip_delay = 0.4;
@@ -476,4 +447,79 @@ mod tests {
assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"})));
assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme"));
}
+
+ #[test]
+ fn application_frames_follow_system_appearance_and_preserve_manual_palettes() {
+ // Mixer rendering tests also change the process-wide custom-painted palette.
+ let _palette = lock_test_palette();
+ fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option, light: bool) {
+ let mode = app.ui.theme;
+ let colors = |app: &SoundApp| {
+ app.engine
+ .session()
+ .tracks
+ .iter()
+ .map(|track| {
+ (track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::>())
+ })
+ .collect::>()
+ };
+ let before = colors(app);
+ let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ let tokens = if light { Tokens::LIGHT } else { Tokens::DARK };
+ assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}");
+ assert_eq!(Tokens::current().panel_bg, tokens.panel_bg);
+ assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System);
+ for theme in [egui::Theme::Light, egui::Theme::Dark] {
+ let style = ctx.style_of(theme);
+ assert_eq!(style.visuals.dark_mode, !light);
+ assert_eq!(style.visuals.panel_fill, tokens.panel_bg);
+ assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button);
+ assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text);
+ assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0));
+ }
+ app.ui(ui);
+ assert_eq!(ui.visuals().panel_fill, tokens.panel_bg);
+ assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button);
+ });
+ output.textures_delta.clear();
+ assert_eq!(app.ui.theme, mode);
+ assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id());
+ assert_eq!(colors(app), before, "appearance must preserve track and clip colors");
+ }
+
+ let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None];
+ for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] {
+ let ctx = egui::Context::default();
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap();
+ frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light));
+ }
+
+ let ctx = egui::Context::default();
+ let mut engine = soundcraft_engine::demo::demo_engine();
+ let track = engine.session_mut().tracks.first_mut().unwrap();
+ track.color = [19, 83, 147];
+ track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]);
+ let mut app = SoundApp::new(engine, None, Services::default());
+ for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] {
+ app.run("ui.theme", json!({"mode": mode.id()})).unwrap();
+ for appearance in appearances {
+ let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light));
+ frame(&mut app, &ctx, appearance, light);
+ }
+ if mode != ThemeMode::System {
+ let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light };
+ frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light);
+ app.run("ui.theme", json!({"mode": "system"})).unwrap();
+ // Resolve the current context immediately, without another OS appearance event.
+ app.logic(&ctx);
+ assert_eq!(is_light(), latest == egui::Theme::Light);
+ assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light);
+ frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light);
+ }
+ }
+ }
}
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index da913a8..1eca07c 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -1,24 +1,33 @@
//! Small custom widgets in the studio style.
+use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
+
use crate::icons;
use crate::theme::{Tokens, bold, regular};
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
+/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar.
+fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle {
+ let t = Tokens::current();
+ let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::new(1.0, t.button_border), icon };
+ IconButtonStyle {
+ normal: visuals(t.button, normal_icon),
+ hovered: visuals(t.button_hi, normal_icon),
+ pressed: None,
+ selected: visuals(selected_fill, Color32::WHITE),
+ corner_radius: CornerRadius::same(radius),
+ focus_stroke: Stroke::new(1.0, t.accent),
+ }
+}
+
/// A toolbar button with an icon; `on` draws the blue selected state.
pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response {
let t = Tokens::current();
- let (r, resp) = ui.allocate_exact_size(size, Sense::click());
- let fill = if on {
- t.accent
- } else if resp.hovered() {
- t.button_hi
- } else {
- t.button
- };
- ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
- let col = if on { Color32::WHITE } else { t.text };
- let s = r.height().min(r.width()) * 0.78;
- icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col);
+ let style = button_style(t.accent, t.text, 3);
+ let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
+ let s = r.height().min(r.width()) * 0.78;
+ icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint);
+ });
resp.on_hover_text(tip)
}
@@ -39,24 +48,22 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color
resp.on_hover_text(tip)
}
-/// Record-enable button (red dot).
+/// Record-enable button (red dot); the caller owns track arming and command dispatch.
pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response {
- let t = Tokens::current();
- let (r, resp) = ui.allocate_exact_size(size, Sense::click());
- let fill = if on {
- t.rec
- } else if resp.hovered() {
- t.button_hi
- } else {
- t.button
- };
- ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
- ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) });
+ let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2);
+ let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
+ ui.painter().circle_filled(r.center(), size.y * 0.24, tint);
+ });
resp.on_hover_text(tip)
}
/// A dropdown-looking selector box. Returns the response (caller opens a menu on click).
pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response {
+ selector_box_with_tooltip(ui, width, height, text, color, text)
+}
+
+/// A selector with a compact visible label and the complete meaning on hover.
+pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
ui.painter().rect(
@@ -68,10 +75,15 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col
);
let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max);
icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim);
- let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0));
- let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5);
+ let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0));
+ let mut job = egui::text::LayoutJob::simple(text.to_owned(), regular((height * 0.62).clamp(8.0, 12.0)), color, text_rect.width().max(1.0));
+ job.wrap.max_rows = 1;
+ job.wrap.break_anywhere = true;
+ job.wrap.overflow_character = Some('…');
+ let galley = ui.painter().layout_job(job);
+ let pos = text_rect.center() - galley.size() * 0.5;
ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color);
- resp
+ resp.on_hover_text(tooltip)
}
/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.
@@ -371,3 +383,201 @@ mod tests {
}
}
}
+
+#[cfg(test)]
+mod selector_visual_regressions {
+ use super::*;
+
+ fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
+ let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
+ input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
+ input
+ }
+
+ #[test]
+ fn capture_selector_visual_fixtures() {
+ let _palette = crate::theme::lock_test_palette();
+ let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
+ std::fs::create_dir_all(&directory).unwrap();
+ for scale in [1.0, 1.5, 2.0] {
+ let mut ready = false;
+ let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 120.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ move |ui, regions: &mut serde_json::Map| {
+ if !ready {
+ crate::fonts::install(ui.ctx());
+ crate::theme::apply(ui.ctx(), false);
+ ready = true;
+ return;
+ }
+ regions.clear();
+ for (index, label) in ["auto read", "no group", "A very long mixer group name"].into_iter().enumerate() {
+ ui.horizontal(|ui| {
+ for width in [46.0, 74.0, 110.0] {
+ let r = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
+ regions.insert(format!("selector-{index}-{width}"), serde_json::json!([r.left(), r.top(), r.width(), r.height()]));
+ }
+ });
+ }
+ },
+ serde_json::Map::new(),
+ );
+ harness.input_mut().max_texture_side = Some(8192);
+ harness.run_steps(4);
+ let stem = format!("soundcraft-selectors-dark-{scale}x");
+ harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
+ std::fs::write(
+ directory.join(format!("{stem}.json")),
+ serde_json::to_vec_pretty(&serde_json::json!({
+ "screen": [320.0, 120.0], "scale": scale, "regions": harness.state()
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+ }
+ }
+
+ fn mixer_app(narrow: bool, mode: soundcraft_model::AutomationMode) -> crate::SoundApp {
+ let mut app = crate::SoundApp::new(soundcraft_engine::Engine::default(), None, Default::default());
+ app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap();
+ app.run("track.new", serde_json::json!({"name": "Music"})).unwrap();
+ let ids: Vec<_> = app.engine.session().tracks.iter().map(|track| track.id).collect();
+ for id in ids {
+ app.run("mix.automation_mode", serde_json::json!({"track": id.0, "mode": mode.label()})).unwrap();
+ }
+ app.ui.narrow_mix = narrow;
+ app.ui.show_tracks_list = false;
+ app.ui.mix_views = vec!["io".into()];
+ app
+ }
+
+ #[test]
+ fn actual_mixer_keeps_every_active_automation_mode_legible() {
+ let _palette = crate::theme::lock_test_palette();
+ for narrow in [false, true] {
+ for mode in soundcraft_model::AutomationMode::ALL {
+ for scale in [1.0, 1.5, 2.0] {
+ let ctx = egui::Context::default();
+ crate::fonts::install(&ctx);
+ crate::theme::apply(&ctx, false);
+ let mut app = mixer_app(narrow, mode);
+ let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui));
+ output.textures_delta.clear();
+ assert_eq!(ctx.content_rect().size(), vec2(320.0, 640.0));
+ assert_eq!(ctx.pixels_per_point(), scale);
+ let long = format!("auto {}", mode.label());
+ let short = if mode == soundcraft_model::AutomationMode::TouchLatch { "t/l" } else { mode.label() };
+ let texts: Vec<_> = output
+ .shapes
+ .iter()
+ .filter_map(|shape| match &shape.shape {
+ egui::Shape::Text(text)
+ if text.galley.job.text == long || text.galley.job.text == mode.label() || text.galley.job.text == short =>
+ {
+ Some(text)
+ }
+ _ => None,
+ })
+ .collect();
+ assert_eq!(texts.len(), 2, "both tracks paint their automation mode: {mode:?}");
+ for text in texts {
+ assert_eq!(text.galley.rows.len(), 1, "{mode:?} narrow={narrow} at {scale}x: automation text wraps");
+ assert!(
+ !text.galley.elided,
+ "{mode:?} narrow={narrow} at {scale}x: the active mode must remain distinguishable without hovering"
+ );
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn capture_actual_mixer_visual_fixtures() {
+ let _palette = crate::theme::lock_test_palette();
+ let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
+ std::fs::create_dir_all(&directory).unwrap();
+ for mode in soundcraft_model::AutomationMode::ALL {
+ for narrow in [false, true] {
+ for scale in [1.0, 1.5, 2.0] {
+ let mut ready = false;
+ let mut harness =
+ egui_kittest::Harness::builder().with_size(vec2(320.0, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ move |ui, app: &mut crate::SoundApp| {
+ if !ready {
+ crate::fonts::install(ui.ctx());
+ crate::theme::apply(ui.ctx(), false);
+ ready = true;
+ return;
+ }
+ crate::mix_window::show(app, ui);
+ },
+ mixer_app(narrow, mode),
+ );
+ harness.input_mut().max_texture_side = Some(8192);
+ harness.run_steps(4);
+ let stem = format!("soundcraft-mixer-narrow-{narrow}-{}-{scale}x", mode.label().replace('/', "-"));
+ harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
+ let strips = harness
+ .output()
+ .shapes
+ .iter()
+ .filter_map(|shape| match &shape.shape {
+ egui::Shape::Rect(rect)
+ if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 =>
+ {
+ Some(rect.rect)
+ }
+ _ => None,
+ })
+ .fold(Rect::NOTHING, Rect::union);
+ assert!(strips.is_finite());
+ std::fs::write(
+ directory.join(format!("{stem}.json")),
+ serde_json::to_vec_pretty(&serde_json::json!({
+ "screen": [320.0, 640.0], "scale": scale, "mode": mode.label(), "narrow": narrow,
+ "regions": {"mixer-selectors": [strips.left(), strips.top(), strips.width(), 120.0]}
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn compact_mixer_selectors_keep_labels_on_one_visible_line() {
+ let _palette = crate::theme::lock_test_palette();
+ for width in [46.0, 74.0, 110.0] {
+ for label in ["auto read", "no group", "A very long mixer group name"] {
+ for scale in [1.0, 1.5, 2.0] {
+ let ctx = egui::Context::default();
+ crate::fonts::install(&ctx);
+ crate::theme::apply(&ctx, false);
+ let mut rect = Rect::NOTHING;
+ let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| {
+ rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
+ });
+ output.textures_delta.clear();
+ assert_eq!(ctx.content_rect().size(), vec2(320.0, 120.0));
+ assert_eq!(ctx.pixels_per_point(), scale);
+ let text = output
+ .shapes
+ .iter()
+ .find_map(|shape| match &shape.shape {
+ egui::Shape::Text(text) if text.galley.job.text == label => Some(text),
+ _ => None,
+ })
+ .expect("selector label is painted");
+ assert_eq!(text.galley.rows.len(), 1, "{label:?} at {width} must not wrap into clipped rows");
+ let bounds = text.visual_bounding_rect();
+ assert!(bounds.top() >= rect.top() && bounds.bottom() <= rect.bottom(), "{label:?}: {bounds:?} outside {rect:?}");
+ assert!(
+ bounds.left() >= rect.left() && bounds.right() <= rect.right() - 16.0 * 0.8,
+ "text must leave room for the dropdown arrow"
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
index 96677a6..1e51a2d 100644
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -383,10 +383,15 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
if ui.button(format!("{} {name}", i + 1)).clicked()
&& let Ok(mut st) = serde_json::from_value::(v.clone())
{
- st.configurations = app.ui.configurations.clone();
- st.theme = app.ui.theme;
- st.show_configurations = true;
- app.ui = st;
+ if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) {
+ app.ui.status = format!("Window configuration could not be restored: {error}");
+ } else {
+ st.configurations = app.ui.configurations.clone();
+ st.theme = app.ui.theme;
+ st.show_configurations = true;
+ crate::docking::cancel_replaced_state(app, &st);
+ app.ui = st;
+ }
}
if ui.small_button("✕").clicked() {
app.ui.configurations.remove(i);
@@ -446,11 +451,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
let r = ui.text_edit_singleline(&mut edit);
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
if r.lost_focus() && edit != name {
- let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit}));
+ let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit}));
}
ui.label(fmt);
if ui.small_button("Delete").clicked() {
- let _ = app.run("mix.bus_delete", json!({"bus": name}));
+ let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name}));
}
ui.end_row();
}
diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/appearance.rs
new file mode 100644
index 0000000..87145a8
--- /dev/null
+++ b/crates/ui-egui/tests/appearance.rs
@@ -0,0 +1,47 @@
+use egui::accesskit::Role;
+use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use soundcraft_ui_egui::{Services, SoundApp};
+
+const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0);
+
+fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option) {
+ let zoom = h.ctx.zoom_factor();
+ h.set_size(WINDOW_PX / zoom);
+ let pos = (px.to_vec2() / zoom).to_pos2();
+ h.input_mut().events.push(Event::PointerMoved(pos));
+ if let Some(pressed) = pressed {
+ h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
+ }
+ h.step();
+}
+
+#[test]
+fn interface_scale_slider_follows_the_pointer() {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.extra.show_ui_customization = true;
+ let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state(
+ |ui, app: &mut SoundApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(6);
+ let rail = h.get_by_role(Role::Slider).rect();
+ let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y);
+ let end = pos2(rail.right() + 40.0, rail.center().y);
+ frame(&mut h, start, Some(true));
+ let mut scales = vec![];
+ for i in 1..=20 {
+ frame(&mut h, start.lerp(end, i as f32 / 20.0), None);
+ scales.push(h.state().extra.ui_scale);
+ }
+ assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}");
+ frame(&mut h, end, Some(false));
+ frame(&mut h, end, None);
+ assert_eq!(h.state().extra.ui_scale, 1.5);
+ assert_eq!(h.ctx.zoom_factor(), 1.5);
+}
diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs
new file mode 100644
index 0000000..c2cdf33
--- /dev/null
+++ b/crates/ui-egui/tests/docking_workflows.rs
@@ -0,0 +1,159 @@
+//! Real Edit/Mix panel workflows, persistence, and command ownership without OS windows.
+use egui::{Event, Modifiers, PointerButton, vec2};
+use egui_kittest::{Harness, kittest::Queryable};
+use serde_json::json;
+use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel};
+
+fn harness(state: Option, width: f32, scale: f32) -> Harness<'static, SoundApp> {
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ app.run("track.new", json!({"name":"Voice"})).unwrap();
+ if let Some(state) = state {
+ app.ui = state;
+ }
+ let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(5);
+ h
+}
+
+fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) {
+ h.event(Event::PointerMoved(from));
+ h.run_steps(2);
+ h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
+ h.run_steps(2);
+ for step in 1..=8 {
+ h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
+ h.run_steps(1);
+ }
+ h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
+ h.run_steps(4);
+}
+
+#[test]
+fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
+ let mut h = harness(None, 1400.0, 1.0);
+ let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").rect().center();
+ let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center();
+ drag(&mut h, source, target);
+ assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks));
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
+ h.run_steps(3);
+ h.get_by_label("Float panel").click();
+ h.run_steps(5);
+ let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect;
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary);
+ h.run_steps(3);
+ h.get_by_label("Close panel").click();
+ h.run_steps(4);
+ assert!(!h.state().ui.show_clip_list);
+ h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
+ h.run_steps(4);
+ assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect);
+ let saved = serde_json::to_string(&h.state().ui).unwrap();
+ let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
+ let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
+ assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
+ reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap();
+ reloaded.run_steps(4);
+ reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
+ reloaded.run_steps(3);
+ reloaded.get_by_label("Trim (F6)").click();
+ reloaded.run_steps(3);
+ assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
+ assert!(!reloaded.state().engine.transport.playing);
+ let edit = serde_json::to_value(&reloaded.state().ui.docking).unwrap();
+ reloaded.state_mut().ui.window = MainWindow::Mix;
+ reloaded.run_steps(5);
+ reloaded.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[900,70,220,420]})).unwrap();
+ reloaded.run_steps(4);
+ assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit);
+ assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips));
+ let record = reloaded.get_by_label("Record enable");
+ // A real pointer click must remain outside the floating panel's occluding rectangle.
+ assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center()));
+ record.click();
+ reloaded.run_steps(3);
+ assert!(reloaded.state().engine.session().tracks[0].mixer.record_arm);
+ assert!(!reloaded.state().engine.transport.recording);
+}
+
+#[test]
+fn invalid_docking_requests_are_atomic_in_edit_and_mix() {
+ let mut h = harness(None, 1400.0, 1.0);
+ for window in [MainWindow::Edit, MainWindow::Mix] {
+ h.state_mut().ui.window = window;
+ h.run_steps(4);
+ let before = serde_json::to_value(&h.state().ui).unwrap();
+ for params in [
+ json!({"operation":"close","panel":"canvas"}),
+ json!({"operation":"move","panel":"tracks","target":"canvas","zone":"center"}),
+ json!({"operation":"float","panel":"tracks","rect":[0,0,-1,100]}),
+ ] {
+ assert!(h.state_mut().run("ui.dock", params).is_err());
+ assert_eq!(serde_json::to_value(&h.state().ui).unwrap(), before);
+ }
+ if window == MainWindow::Mix {
+ assert!(h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).is_err());
+ }
+ assert_eq!(h.state().engine.session().tracks.len(), 1);
+ }
+}
+
+#[test]
+fn docking_renders_edit_and_mix_default_floating_and_redocked() {
+ for width in [1000.0, 1400.0] {
+ for scale in [1.0, 2.0] {
+ for window in [MainWindow::Edit, MainWindow::Mix] {
+ let mut h = harness(Some(UiState { window, ..Default::default() }), width, scale);
+ h.state_mut().engine = soundcraft_engine::demo::demo_engine();
+ for state in ["default", "floating", "redocked"] {
+ if state == "floating" {
+ h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[100,80,260,500]})).unwrap();
+ } else if state == "redocked" {
+ h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap();
+ }
+ h.run_steps(5);
+ h.state().ui.docking.validate().unwrap();
+ h.state().ui.mix_docking.validate().unwrap();
+ if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
+ std::fs::create_dir_all(&directory).unwrap();
+ h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap();
+ }
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() {
+ use soundcraft_ui_egui::menus;
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ let path = "Window > Reset Panel Layout";
+ let extra: Vec<_> = menus::UI_COMMANDS.iter().map(|(id, _, path, _)| (*path, *id)).collect();
+ assert_eq!(menus::command_for_path(path, &extra).as_deref(), Some("ui.dock.reset"));
+ let tree = menus::tree();
+ let window = tree.iter().find(|node| node.label == "Window").unwrap();
+ assert!(window.children.iter().any(|node| node.path == path));
+ assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0);
+ app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap();
+ menus::invoke_menu(&mut app, "ui.dock.reset", path);
+ assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks)));
+}
+
+#[test]
+fn legacy_preferences_restore_the_theme_and_validate_default_docking() {
+ let mut value = serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, ..Default::default() }).unwrap();
+ let object = value.as_object_mut().unwrap();
+ object.remove("docking");
+ object.remove("mix_docking");
+ let restored: UiState = serde_json::from_value(value).unwrap();
+ assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
+ restored.docking.validate().unwrap();
+ restored.mix_docking.validate_mix().unwrap();
+}
diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs
new file mode 100644
index 0000000..56cb12d
--- /dev/null
+++ b/crates/ui-egui/tests/midi_panel.rs
@@ -0,0 +1,152 @@
+use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
+use serde_json::json;
+use soundcraft_ui_egui::{Services, SoundApp, midi_editor};
+
+fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) {
+ let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
+ ctx.run_ui(raw, |ui| {
+ app.logic(ui.ctx());
+ app.ui(ui);
+ })
+ .textures_delta
+ .clear();
+}
+
+fn panel(ctx: &egui::Context) -> Rect {
+ ctx.data(|data| data.get_temp::(Id::new("soundcraft-midi-body"))).unwrap()
+}
+
+fn button(pos: Pos2, pressed: bool) -> Event {
+ Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
+}
+
+fn check_resize(with_clip: bool) {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.ui.show_midi_editor = true;
+ if with_clip {
+ app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
+ }
+ assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip);
+ let ctx = egui::Context::default();
+ for _ in 0..6 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ let initial = panel(&ctx);
+ assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}");
+ let notes = app.engine.session().clone();
+
+ // Drag the shared boundary up and down, then allow several idle frames
+ // to catch a panel that looks resized but snaps back after release.
+ for height in [440.0, 280.0, 200.0] {
+ let before = panel(&ctx);
+ let start = pos2(before.center().x, before.top() - 26.0 - 2.0);
+ let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0);
+ frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
+ frame(&ctx, &mut app, vec![button(start, true)]);
+ for step in 1..=6 {
+ frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]);
+ }
+ frame(&ctx, &mut app, vec![button(end, false)]);
+ for _ in 0..4 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ let after = panel(&ctx);
+ assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}");
+ assert!((after.bottom() - initial.bottom()).abs() < 1.0);
+ assert!(app.edit_layout.timeline[3] <= after.top());
+ }
+
+ // Closing and reopening the dock keeps the chosen height.
+ let resized = panel(&ctx);
+ app.run("window.midi_editor", json!({})).unwrap();
+ frame(&ctx, &mut app, vec![]);
+ app.run("window.midi_editor", json!({})).unwrap();
+ for _ in 0..4 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ assert_eq!(panel(&ctx), resized);
+ let saved = serde_json::to_string(&app.ui).unwrap();
+ app.ui = serde_json::from_str(&saved).unwrap();
+ for _ in 0..4 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ assert_eq!(panel(&ctx), resized);
+ assert_eq!(*app.engine.session(), notes);
+}
+
+#[test]
+fn midi_panel_resizes_with_a_selected_clip() {
+ check_resize(true);
+}
+
+#[test]
+fn midi_panel_resizes_without_a_selected_clip() {
+ check_resize(false);
+}
+
+#[test]
+fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() {
+ check_held_replacement("reset");
+}
+
+#[test]
+fn arrange_cancels_a_held_midi_divider_without_editing_the_session() {
+ check_held_replacement("arrange");
+}
+
+#[test]
+fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() {
+ check_held_replacement("visibility");
+}
+
+fn check_held_replacement(operation: &str) {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.ui.show_midi_editor = true;
+ app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
+ app.ui.show_memory_locations = operation == "arrange";
+ let session = app.engine.session().clone();
+ let ctx = egui::Context::default();
+ for _ in 0..6 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ let initial = panel(&ctx);
+ let start = pos2(initial.center().x, initial.top() - 28.0);
+ frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
+ frame(&ctx, &mut app, vec![button(start, true)]);
+ let first = start - vec2(0.0, 80.0);
+ frame(&ctx, &mut app, vec![Event::PointerMoved(first)]);
+ frame(&ctx, &mut app, vec![]);
+ assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize");
+ let second = start - vec2(0.0, 120.0);
+ frame(&ctx, &mut app, vec![Event::PointerMoved(second)]);
+ frame(&ctx, &mut app, vec![]);
+ assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing");
+ match operation {
+ "reset" => {
+ app.run("ui.dock.reset", json!({})).unwrap();
+ }
+ "arrange" => {
+ app.run("window.arrange_cascade", json!({})).unwrap();
+ }
+ "visibility" => {
+ app.run("window.midi_editor", json!({})).unwrap();
+ frame(&ctx, &mut app, vec![]);
+ app.run("window.midi_editor", json!({})).unwrap();
+ frame(&ctx, &mut app, vec![]);
+ }
+ _ => panic!("unknown synthetic operation"),
+ }
+ let reset = serde_json::to_value(&app.ui.docking).unwrap();
+ let after_reset = start - vec2(0.0, 160.0);
+ frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]);
+ frame(&ctx, &mut app, vec![]);
+ assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout");
+ let expected = if operation == "reset" { 320.0 } else { 440.0 };
+ assert!((panel(&ctx).height() - expected).abs() < 1.0);
+ frame(&ctx, &mut app, vec![button(after_reset, false)]);
+ for _ in 0..4 {
+ frame(&ctx, &mut app, vec![]);
+ }
+ assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset);
+ assert_eq!(*app.engine.session(), session);
+}
diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs
new file mode 100644
index 0000000..fb33610
--- /dev/null
+++ b/crates/ui-egui/tests/mix_instrument_contrast.rs
@@ -0,0 +1,172 @@
+use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
+use egui_kittest::kittest::Queryable;
+use serde_json::json;
+use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
+
+#[derive(Debug)]
+struct Slot {
+ rect: Rect,
+ fill: Color32,
+ text: Color32,
+ border: Color32,
+}
+
+// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
+// testing a palette token that a custom-painted control might never use.
+fn slot(output: &FullOutput, label: &str) -> Slot {
+ let text = output
+ .shapes
+ .iter()
+ .find_map(|shape| match &shape.shape {
+ Shape::Text(text) if text.galley.job.text == label => Some(text),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("missing painted label {label}"));
+ let center = text.visual_bounding_rect().center();
+ let rect = output
+ .shapes
+ .iter()
+ .find_map(|shape| match &shape.shape {
+ Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("missing painted slot for {label}"));
+ let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
+ assert_eq!(text.opacity_factor, 1.0);
+ assert_eq!(foreground.a(), 255);
+ assert_eq!(rect.fill.a(), 255);
+ Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
+}
+
+fn contrast(a: Color32, b: Color32) -> f64 {
+ let luminance = |color: Color32| {
+ let linear = |channel: u8| {
+ let s = f64::from(channel) / 255.0;
+ if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
+ };
+ 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
+ };
+ let (a, b) = (luminance(a), luminance(b));
+ (a.max(b) + 0.05) / (a.min(b) + 0.05)
+}
+
+#[test]
+fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ assert_eq!(app.ui.theme, ThemeMode::Dark);
+ // Keep demo audio loaded for the preservation check while isolating the new strips.
+ for track in &mut app.engine.session_mut().tracks {
+ track.hidden = true;
+ }
+ app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
+ let instrument = app.engine.session().edit.selected_tracks[0];
+ assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
+ app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
+ app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
+ // An unassigned instrument is a supported model state; track.new normally supplies a synth.
+ let empty = app.engine.session().edit.selected_tracks[0];
+ app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
+ app.run("window.mix", json!({})).unwrap();
+ app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
+ for section in ["instruments", "inserts_ae"] {
+ app.run("view.mix_section", json!({"section": section})).unwrap();
+ }
+ let session = app.engine.session().clone();
+ assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
+ let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
+ let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
+ if proof_dir.is_some() {
+ builder = builder.wgpu();
+ }
+ let mut harness = builder.build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(ui.ctx());
+ app.ui(ui);
+ },
+ app,
+ );
+ harness.input_mut().max_texture_side = Some(8192);
+ let mut failures = Vec::new();
+ for (name, mode, system, light) in [
+ ("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
+ ("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
+ ("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
+ ("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
+ ] {
+ harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
+ harness.input_mut().system_theme = system;
+ harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
+ for _ in 0..4 {
+ harness.step();
+ }
+ let ordinary = slot(harness.output(), "SubSynth");
+ let insert = slot(harness.output(), "EQ7");
+ let empty = slot(harness.output(), "no instrument");
+ let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
+ assert_eq!(insert.fill, palette.insert_on);
+ assert_eq!(empty.fill, palette.slot_bg);
+ assert_eq!(ordinary.text, palette.text);
+ if ordinary.fill != insert.fill || ordinary.border != insert.border {
+ failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
+ }
+ for hovered in [false, true] {
+ if hovered {
+ harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
+ harness.step();
+ harness.step();
+ }
+ let painted = slot(harness.output(), "SubSynth");
+ let ratio = contrast(painted.text, painted.fill);
+ println!(
+ "{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
+ painted.text.to_array(),
+ painted.fill.to_array(),
+ painted.rect
+ );
+ if ratio < 4.5 {
+ failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
+ }
+ if !light {
+ let original = Color32::from_rgb(52, 62, 80);
+ assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
+ assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
+ }
+ if let Some(dir) = &proof_dir {
+ harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
+ }
+ }
+ assert_eq!(harness.state().ui.theme, mode);
+ assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+
+ // Exercise the same painted control's ordinary click/picker path after palette changes.
+ let at = slot(harness.output(), "SubSynth").rect.center();
+ for pressed in [true, false] {
+ harness.input_mut().events.push(Event::PointerButton {
+ pos: at,
+ button: egui::PointerButton::Primary,
+ pressed,
+ modifiers: Default::default(),
+ });
+ harness.step();
+ }
+ harness.step(); // Let the popup finish its initial sizing pass.
+ harness.get_by_label_contains("Built-in").hover();
+ harness.step();
+ harness.step();
+ harness.get_by_label("Drum Synth").click();
+ harness.step();
+ harness.step();
+ let app = harness.state();
+ let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
+ assert_eq!(replacement.plugin, "drum_synth");
+ for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
+ assert_eq!(replacement.params.get(param.id), Some(¶m.default));
+ }
+ assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
+ assert_eq!(app.engine.session().pool, session.pool);
+ assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
+ harness.state_mut().run("edit.undo", json!({})).unwrap();
+ assert_eq!(harness.state().engine.session(), &session);
+}
diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/shared_controls.rs
new file mode 100644
index 0000000..71ae5de
--- /dev/null
+++ b/crates/ui-egui/tests/shared_controls.rs
@@ -0,0 +1,217 @@
+//! Interaction and optional offscreen evidence for the application's painted controls.
+
+use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2};
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use soundcraft_ui_egui::{Services, SoundApp, theme, widgets};
+
+#[derive(Default)]
+struct ButtonState {
+ record: bool,
+ selected: bool,
+ disabled: bool,
+ clicks: usize,
+ response: Option,
+}
+
+impl ButtonState {
+ fn label(&self) -> &'static str {
+ if self.record { "Record enable" } else { "Loop playback" }
+ }
+}
+
+fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec) -> egui::FullOutput {
+ let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
+ let response = ui
+ .add_enabled_ui(!state.disabled, |ui| {
+ if state.record {
+ widgets::rec_toggle(ui, vec2(38.0, 28.0), state.selected, state.label())
+ } else {
+ widgets::icon_button(ui, vec2(38.0, 28.0), "loop", state.selected, state.label())
+ }
+ })
+ .inner;
+ state.clicks += usize::from(response.clicked());
+ state.response = Some(response);
+ });
+ output.textures_delta.clear();
+ output
+}
+
+fn key(key: Key, pressed: bool) -> Event {
+ Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
+}
+
+fn pointer(pos: egui::Pos2, pressed: bool) -> Event {
+ Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
+}
+
+#[test]
+fn tool_and_record_buttons_preserve_distinct_selected_colors_and_caller_state() {
+ for record in [false, true] {
+ let ctx = egui::Context::default();
+ ctx.enable_accesskit();
+ let mut state = ButtonState { record, selected: true, ..Default::default() };
+ button_frame(&ctx, &mut state, vec![]);
+ let center = state.response.as_ref().unwrap().rect.center();
+ let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
+ let expected = if record { theme::Tokens::current().rec } else { theme::Tokens::current().accent };
+ assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == expected)));
+ if record {
+ assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == egui::Color32::WHITE)));
+ }
+ let update = out.platform_output.accesskit_update.unwrap();
+ let node = &update.nodes.iter().find(|(_, n)| n.label() == Some(state.label())).unwrap().1;
+ assert_eq!(node.role(), egui::accesskit::Role::Button);
+ assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True));
+ button_frame(&ctx, &mut state, vec![pointer(center, false)]);
+ assert_eq!(state.clicks, 1);
+ assert!(state.selected, "selection and record arming remain caller-owned");
+ }
+}
+
+#[test]
+fn tool_and_record_buttons_handle_keyboard_disabled_state_and_cancelled_clicks() {
+ for record in [false, true] {
+ for key_code in [Key::Enter, Key::Space] {
+ let ctx = egui::Context::default();
+ ctx.enable_accesskit();
+ let mut state = ButtonState { record, ..Default::default() };
+ button_frame(&ctx, &mut state, vec![]);
+ button_frame(&ctx, &mut state, vec![]);
+ let response = state.response.clone().unwrap();
+ ctx.memory_mut(|m| m.request_focus(response.id));
+ let out = button_frame(&ctx, &mut state, vec![]);
+ assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r)
+ if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::current().accent)));
+ button_frame(&ctx, &mut state, vec![key(key_code, true)]);
+ button_frame(&ctx, &mut state, vec![key(key_code, false)]);
+ assert_eq!(state.clicks, 1);
+ let center = response.rect.center();
+ button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
+ button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]);
+ button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]);
+ assert_eq!(state.clicks, 1);
+ state.disabled = true;
+ button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]);
+ let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]);
+ assert_eq!(state.clicks, 1);
+ assert!(!state.response.as_ref().unwrap().enabled());
+ assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some(state.label()) && n.is_disabled()));
+ }
+ }
+}
+
+#[test]
+fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions() {
+ let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(5);
+ h.get_by_label("Trim (F6)").click();
+ h.run_steps(3);
+ assert_eq!(h.state().engine.session().edit.tool, soundcraft_model::Tool::Trim);
+ let markers = h.state().engine.session().markers.len();
+ for (label, tool, key) in
+ [("Pencil (F10)", soundcraft_model::Tool::Pencil, Key::Enter), ("Grabber (F8)", soundcraft_model::Tool::Grabber, Key::Space)]
+ {
+ h.get_by_label(label).focus();
+ h.run_steps(2);
+ assert!(h.get_by_label(label).is_focused());
+ h.key_press(key);
+ h.run_steps(3);
+ assert_eq!(h.state().engine.session().edit.tool, tool);
+ assert!(!h.state().engine.transport.playing);
+ assert!(!h.state().engine.transport.recording);
+ assert_eq!(h.state().engine.session().markers.len(), markers);
+ }
+}
+
+#[test]
+fn track_record_button_arms_the_track_without_starting_transport() {
+ let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+ app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap();
+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ },
+ app,
+ );
+ h.run_steps(5);
+ h.get_by_label("Record enable").click();
+ h.run_steps(3);
+ assert!(h.state().engine.session().tracks[0].mixer.record_arm);
+ for (key, armed) in [(Key::Enter, false), (Key::Space, true)] {
+ h.get_by_label("Record enable").focus();
+ h.run_steps(2);
+ h.key_press(key);
+ h.run_steps(3);
+ assert_eq!(h.state().engine.session().tracks[0].mixer.record_arm, armed);
+ assert!(!h.state().engine.transport.playing);
+ assert!(!h.state().engine.transport.recording);
+ assert!(h.state().engine.session().markers.is_empty());
+ }
+}
+
+#[test]
+fn shared_controls_visuals() {
+ let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return };
+ std::fs::create_dir_all(&directory).unwrap();
+ for scale in [1.0, 2.0] {
+ let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ },
+ app,
+ );
+ h.input_mut().max_texture_side = Some(8192);
+ for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(1000.0, 700.0))] {
+ h.set_size(size);
+ h.run_steps(6);
+ h.render().unwrap().save(directory.join(format!("soundcraft-{name}-dark-{scale}x.png"))).unwrap();
+ }
+ let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ |ui, rects: &mut Vec| {
+ theme::apply(ui.ctx(), false);
+ rects.clear();
+ for enabled in [true, false] {
+ ui.add_enabled_ui(enabled, |ui| {
+ ui.horizontal(|ui| {
+ for selected in [false, true] {
+ rects.push(widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop Playback").rect);
+ rects.push(widgets::icon_button(ui, vec2(36.0, 26.0), "trim", selected, "Trim").rect);
+ rects.push(widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable").rect);
+ }
+ });
+ });
+ }
+ },
+ Vec::new(),
+ );
+ controls.run_steps(4);
+ controls.render().unwrap().save(directory.join(format!("soundcraft-controls-dark-{scale}x.png"))).unwrap();
+ for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] {
+ let center = controls.state()[index].center();
+ controls.event(Event::PointerMoved(center));
+ if pressed {
+ controls.event(pointer(center, true));
+ }
+ controls.run_steps(2);
+ controls.render().unwrap().save(directory.join(format!("soundcraft-controls-{name}-dark-{scale}x.png"))).unwrap();
+ }
+ controls.event(pointer(controls.state()[0].center(), false));
+ controls.event(Event::PointerGone);
+ controls.run_steps(2);
+ controls.key_press(Key::Tab);
+ controls.run_steps(2);
+ controls.render().unwrap().save(directory.join(format!("soundcraft-controls-focus-dark-{scale}x.png"))).unwrap();
+ }
+}
diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs
index 0844e66..5d04d47 100644
--- a/crates/ui-egui/tests/shortcuts.rs
+++ b/crates/ui-egui/tests/shortcuts.rs
@@ -1,6 +1,7 @@
+use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use serde_json::json;
-use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
+use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
@@ -9,14 +10,37 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers
#[test]
fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
- let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
- let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
- app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
- let ctx = egui::Context::default();
- ctx.set_os(egui::os::OperatingSystem::Mac);
- let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE };
- press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
- press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
- press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
- assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0);
+ let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
+ for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
+ app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
+ let ctx = egui::Context::default();
+ ctx.set_os(os);
+ let ctrl_shift = Modifiers { shift: true, ..ctrl };
+ press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
+ press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
+ press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
+ assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
+ }
+}
+
+#[test]
+fn cmd_equals_toggles_mix_and_edit_without_zooming() {
+ let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+ let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
+ for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ let ctx = egui::Context::default();
+ ctx.set_os(os);
+ ctx.set_zoom_factor(1.25);
+ press(&ctx, &mut app, Key::Equals, cmd);
+ assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}");
+ press(&ctx, &mut app, Key::Equals, cmd);
+ press(&ctx, &mut app, Key::Num0, cmd);
+ press(&ctx, &mut app, Key::Minus, cmd);
+ ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
+ assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}");
+ assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
+ }
}
diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs
new file mode 100644
index 0000000..ed32415
--- /dev/null
+++ b/crates/ui-egui/tests/theme_docking.rs
@@ -0,0 +1,83 @@
+//! Synthetic appearance checks for populated shared panels and custom controls.
+use egui::{Color32, vec2};
+use egui_kittest::Harness;
+use serde_json::json;
+use soundcraft_ui_egui::{Services, SoundApp, theme, widgets};
+
+#[test]
+fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
+ // Keep palette changes in one owning test: upstream custom painters use process-global tokens.
+ for scale in [1.0, 2.0] {
+ let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+ app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
+ for id in ["window.midi_editor", "window.memory_locations", "window.undo_history"] {
+ app.run(id, json!({})).unwrap();
+ }
+ let session = app.engine.session().clone();
+ let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ |ui, app: &mut SoundApp| {
+ app.logic(&ui.ctx().clone());
+ app.ui(ui);
+ },
+ app,
+ );
+ h.input_mut().max_texture_side = Some(8192);
+ for (name, mode, system, light) in [
+ ("dark", theme::ThemeMode::Dark, egui::Theme::Light, false),
+ ("light", theme::ThemeMode::Light, egui::Theme::Dark, true),
+ ("system-light", theme::ThemeMode::System, egui::Theme::Light, true),
+ ("system-dark", theme::ThemeMode::System, egui::Theme::Dark, false),
+ ] {
+ h.state_mut().ui.theme = mode;
+ h.input_mut().system_theme = Some(system);
+ for (width_name, size) in [("normal", vec2(1400.0, 1000.0)), ("narrow", vec2(1000.0, 800.0))] {
+ h.set_size(size);
+ h.run_steps(5);
+ assert_eq!(theme::is_light(), light);
+ assert_eq!(h.state().ui.theme, mode);
+ assert_eq!(*h.state().engine.session(), session);
+ h.state().ui.docking.validate().unwrap();
+ if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
+ std::fs::create_dir_all(&directory).unwrap();
+ h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap();
+ }
+ }
+ let ctx = egui::Context::default();
+ theme::apply(&ctx, light);
+ let tokens = theme::Tokens::current();
+ let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
+ widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback");
+ widgets::rec_toggle(ui, vec2(30.0, 18.0), true, "Record enable");
+ ui.add_enabled_ui(false, |ui| widgets::icon_button(ui, vec2(38.0, 28.0), "loop", false, "Disabled loop"));
+ });
+ output.textures_delta.clear();
+ for expected in [tokens.accent, tokens.rec] {
+ assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == expected)));
+ }
+ assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Circle(circle) if circle.fill == Color32::WHITE)));
+ if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
+ let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
+ move |ui, _: &mut ()| {
+ theme::apply(ui.ctx(), light);
+ for enabled in [true, false] {
+ ui.add_enabled_ui(enabled, |ui| {
+ ui.horizontal(|ui| {
+ for selected in [false, true] {
+ widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop playback");
+ widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable");
+ }
+ });
+ });
+ }
+ },
+ (),
+ );
+ controls.run_steps(4);
+ controls.render().unwrap().save(directory.join(format!("sound-controls-{name}-{scale}x.png"))).unwrap();
+ controls.key_press(egui::Key::Tab);
+ controls.run_steps(2);
+ controls.render().unwrap().save(directory.join(format!("sound-controls-focus-{name}-{scale}x.png"))).unwrap();
+ }
+ }
+ }
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index 3432699..a041434 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,11 +1,21 @@
# Architecture
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`).
Nothing below the UI knows about egui, so the interface can be replaced.
+The UI package pins `craft-ui` for common icon-button and track record-enable interactions.
+SoundCraft supplies each state's colors, border, radius and icon painter; selection and command
+dispatch remain in the toolbar and track controls. The single-shape frame option preserves the
+existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling,
+nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI
+requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration.
+
```
-L0 time audio-io midi (standalone: no workspace deps)
-L1 dsp clap-host (plugins; clap-host is the only unsafe crate)
+L0 time audio-io midi video (standalone: no workspace deps)
+L1 dsp clap-host vst3-host au-host (plugins; the three hosts are the only crates
+ allowed `unsafe`, confined to their FFI modules)
L2 model (the session document)
L3 mix (the mix engine)
L4 engine playback (commands, undo, I/O · audio devices)
@@ -41,6 +51,16 @@ process in parallel; plugin delay compensation aligns every path. The same engin
bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns
it on the audio thread and receives new session snapshots through a channel).
+Device I/O today is the system's *default* output and *default* input device, opened as two
+separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when
+they differ. There is no device or buffer-size selection, no ASIO, and no MIDI device layer yet
+(see [`hardware-parity.md`](hardware-parity.md)). Sources are decoded whole into the `SourcePool`
+and recordings are captured in memory until stop.
+
+`soundcraft-video` holds our own H.264, ProRes and Motion JPEG decoders and the ISO-BMFF demuxer
+for the video track. Hosted plugins (`clap-host`, `vst3-host`, `au-host`) are scanned, created and
+destroyed off the audio thread; only `process` runs on it.
+
The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run
there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does,
so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted
@@ -52,3 +72,10 @@ by the UI thread (see README › Logs).
The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection,
synthetic input and screenshots; `soundcraft-cli mcp` wraps either a headless engine or a running
app as an MCP server. See `control-protocol.md` and `mcp.md`.
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | Added video, vst3-host and au-host to the layer diagram; documented device I/O limits and in-memory media |
+| 2026-10-05 | major | First architecture overview |
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index ba7b079..32a4932 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -12,6 +12,18 @@ input), and every command is reachable from:
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
| MCP (bridged to the app) | `soundcraft-cli mcp --connect ` |
+## Isolated automation profiles
+
+Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`.
+The nonempty value is used as the complete settings directory. Unset retains the normal platform paths;
+an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS`
+when testing preference persistence across restarts.
+
+Run UI acceptance with `--no-audio`. This disables the output player; it does not disable recording
+input opened by arm/monitor commands. An automated UI smoke run must never arm a track, enable input
+monitoring, or start recording. Control input injects egui events; it does not establish operating-system
+input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame.
+
## Wire format
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
@@ -64,4 +76,30 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}'
Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`,
where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or
-`{"steps": n}`.
+`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and
+script events such as `{"system_theme": "dark"}` to change it on subsequent frames.
+
+Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by
+the native or web integration at launch and on later frames, while the saved choice stays System.
+When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay
+fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no
+OS appearance and therefore resolves System to Dark.
+
+## Panel docking
+
+Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
+
+Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
+
+`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
+
+Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.
+
+Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
+`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
+`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
+`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
+A center `move` can include `before` (panel ID or null to append) for tab order.
+The equivalent `{"action": ...}` form accepts a serialized shared docking action;
+this is also the form emitted by the renderer. Protected-canvas and app-specific panel
+rules apply equally to both forms.
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
new file mode 100644
index 0000000..5149502
--- /dev/null
+++ b/docs/file-format-parity.md
@@ -0,0 +1,83 @@
+# File-format parity
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
+its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
+documented import/bounce options. Ours comes from `crates/audio-io` (`detect_format`, `decode`,
+`ENCODE_EXTENSIONS`), `crates/engine/src/cmd/file.rs`, `crates/engine/src/score.rs`,
+`crates/engine/src/clip_group_file.rs` and `crates/video`.
+
+**File formats: ~40 % ready** (estimated: weighted by how often a Pro Tools user hits each row;
+session interchange carries a third of the weight and is at 0 %). Remaining ≈ 70–120 h.
+
+Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not applicable.
+
+## Sessions and interchange
+
+| Format | Pro Tools | Read | Write | Tests / notes |
+|---|---|---|---|---|
+| Pro Tools session `.ptx` (and `.ptf` from 7–9) | native | ❌ | ❌ | Proprietary, undocumented. Owner decision needed on black-box format study (G-03) |
+| Session templates `.ptxt` | read/write | ❌ | ❌ | We have no templates at all (G-16) |
+| AAF (Advanced Authoring Format) | import/export | ❌ | ❌ | #40. The post-production handoff from picture editors (G-03) |
+| OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors |
+| Media Composer–compatible session | export | ❌ | ❌ | AAF variant |
+| ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 |
+| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded |
+| Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only |
+| Session info as text | export | — | ✅ | `file.export_session_text` |
+| Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read |
+| Track presets, fade presets, I/O settings, workspace, sync settings | read/write | ❌ | ❌ | Their equivalents don't exist yet (G-16) |
+
+## Audio
+
+| Format | Pro Tools | Read | Write | Tests / notes |
+|---|---|---|---|---|
+| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read |
+| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
+| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
+| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
+| FLAC | import | ✅ | ✅ | Own encoder |
+| MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 |
+| AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 |
+| ALAC | import | ✅ | ❌ | |
+| Ogg Vorbis | — | ✅ | ❌ | Beyond Pro Tools |
+| CAF | — | ✅ | ❌ | |
+| Sound Designer / SD II | import (legacy) | ❌ | — | Rare today |
+| ReCycle / REX | import | ❌ | — | Loops |
+| MXF audio (OP-Atom, OP1a) | import | ❌ | ❌ | Avid media; FilmCraft has an MXF reader to learn from |
+| iXML / polyphonic field-recorder metadata | read | ❌ | ❌ | G-17 |
+| Peak overviews | `.wfm` cache | ✅ (own, in memory) | — | `audio-io::peaks`; not cached on disk |
+
+## MIDI and notation
+
+| Format | Pro Tools | Read | Write | Tests / notes |
+|---|---|---|---|---|
+| Standard MIDI File (type 0/1) | import/export | ✅ | 🟡 | Exported MIDI omits imported controllers and ignores the tracks filter (#87, #98; PR #125) |
+| MusicXML (to Sibelius) | export | — | ✅ | `file.export_sibelius`, `score.rs` |
+| Printable score | print | — | ✅ (SVG) | |
+
+## Video
+
+| Format | Pro Tools | Read | Write | Tests / notes |
+|---|---|---|---|---|
+| QuickTime / MP4, H.264 | import, bounce | ✅ | ❌ | Own pure-Rust decoder (Baseline/Main/High); mutated-movie fuzzing |
+| ProRes (all flavours) | import | ✅ | ❌ | Own decoder |
+| Motion JPEG | import | ✅ | ❌ | |
+| HEVC, AV1 | import | ❌ | ❌ | G-17 |
+| DNxHD / DNxHR, MXF video | import | ❌ | ❌ | G-17 |
+| Bounce with video | export | — | ❌ | G-17 |
+
+## Plugin and preset files
+
+| Format | Pro Tools | Ours |
+|---|---|---|
+| AAX plug-ins | the only format it hosts | ❌ (cannot: Avid SDK licence, PACE signing) |
+| VST3 / Audio Units / CLAP | — (AAX only) | ✅ hosted (ahead of Pro Tools) |
+| Plug-in settings `.tfx` | read/write | ❌ (our own JSON presets per plugin) |
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
diff --git a/docs/gaps.md b/docs/gaps.md
new file mode 100644
index 0000000..cf3ea5b
--- /dev/null
+++ b/docs/gaps.md
@@ -0,0 +1,200 @@
+# Where SoundCraft falls short of Pro Tools
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha blockers marked after the core-workflow gate) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
+real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
+fail the core-workflow gate in [`roadmap.md`](roadmap.md#alpha-gate) and keep SoundCraft pre-alpha. Numbers and weights live in
+[`target-app-parity.md`](target-app-parity.md); the plan that orders these into milestones is
+[`roadmap.md`](roadmap.md). Hours are Opus 5.5 agent-hours (calibration in target-app-parity.md).
+Estimates marked "≈" are judgements; issue numbers are `storytold/soundcraft` GitHub issues.
+
+When you close a gap, delete its entry (or shrink it) in the same PR, and bump the timestamp.
+
+## Ranked gaps
+
+| # | Gap | Dimension | Doc | Est. (h) |
+|---|---|---|---|---:|
+| G-01 | **Alpha blocker.** No audio device, channel or buffer-size selection; no duplex low-latency path | Hardware | [hardware-parity.md](hardware-parity.md) | 25–40 |
+| G-02 | **Alpha blocker.** No MIDI device input or output | Hardware / MIDI | [hardware-parity.md](hardware-parity.md) | 20–35 |
+| G-03 | **Alpha blocker.** Cannot exchange sessions with Pro Tools (no AAF/OMF, no `.ptx`) | File formats | [file-format-parity.md](file-format-parity.md) | 50–90 |
+| G-04 | Open correctness bugs in features counted as done (~25 issues) | Stability | this file | 25–40 |
+| G-05 | (Entitlement part: alpha blocker for the mix row) Third-party VST3s blocked in the signed macOS build; AU editors missing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 15–25 |
+| G-06 | (Record-to-disk part: alpha blocker for the tracking row) Sources decoded whole into RAM; recording into RAM | Performance | [hardware-parity.md](hardware-parity.md) | 15–25 |
+| G-07 | Nothing is compared against Pro Tools' output (no fidelity harness) | Features | this file | 20–30 |
+| G-08 | Built-in plugin catalogue thin and unverified | Features | [plugin-parity.md](plugin-parity.md) | 80–120 |
+| G-09 | English only, no string catalog, no CJK/RTL rendering | Localization | [localization-parity.md](localization-parity.md) | 70–110 |
+| G-10 | No control surfaces (HUI/MCU, EUCON) | Hardware | [hardware-parity.md](hardware-parity.md) | 40–70 |
+| G-11 | No external sync (MTC, LTC, MIDI clock, video reference) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 |
+| G-12 | Immersive delivery: no Dolby Atmos/ADM BWF, MPEG-H, binaural | Features | [file-format-parity.md](file-format-parity.md) | 50–80 |
+| G-13 | Sidechain inputs, ARA, out-of-process plugin sandboxing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 30–50 |
+| G-14 | Windows/Linux/Web exercised only lightly at runtime; no PR CI running the tests | Platforms / Stability | this file | 20–40 |
+| G-15 | Elastic Audio depth: one algorithm, no Elastic Pitch | Features | [target-app-parity.md](target-app-parity.md) | 30–45 |
+| G-16 | Session templates, track presets, workspace browser, batch rename | Features | [target-app-parity.md](target-app-parity.md) | 25–40 |
+| G-17 | Video: HEVC/DNx decode, video export, external timecode | Features / Formats | [file-format-parity.md](file-format-parity.md) | 30–50 |
+| G-18 | Speech-to-text transcription | AI | this file | 30–50 |
+| G-19 | UI feel: generic plugin windows, shortcut collisions, pro-grade meters and scaling | UI/UX | [ui-parity.md](ui-parity.md) | 60–100 |
+| G-20 | MIDI/instrument depth: MIDI learn, sampler, notation editing | Features | [target-app-parity.md](target-app-parity.md) | 40–60 |
+| G-21 | Compressed export (MP3, AAC/M4A, Ogg/Opus) | File formats | [file-format-parity.md](file-format-parity.md) | 10–20 |
+| G-22 | Avid cloud services (collaboration, Sketch, Splice, sign-in) | Ecosystem | — | not estimated (owner decision) |
+
+## Details
+
+### G-01. Audio interface support
+
+- **Missing:** choosing the output or input device, the device's channel count and routing to
+ physical channels, the buffer size, and the sample rate from the hardware side. Input and
+ output are separate cpal streams on the *default* devices (`crates/playback/src/lib.rs`
+ `open_device`, `record.rs`); the mix runs in fixed 512-sample blocks and resamples when rates
+ differ. `setup.playback_engine` stores `device`/`buffer_size` params, but playback ignores them.
+ No ASIO on Windows (WASAPI shared only), so latency there is high.
+- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42
+ (no input device selection, forced monitoring feedback with virtual routing).
+- **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard.
+- **Estimate:** ≈ 25–40 h (device enumeration and selection, buffer size, duplex stream with one
+ clock, ASIO feature build, per-channel I/O mapping into I/O Setup, latency reporting and
+ record-offset compensation). Needs hands-on hardware testing.
+
+### G-02. MIDI devices
+
+- **Missing:** all MIDI hardware I/O. No `midir`/CoreMIDI/WinMM/ALSA sequencer code exists;
+ `setup.midi_input_devices` and `setup.midi_studio` only store names. You cannot record from a
+ keyboard, play a hosted instrument live, drive external synths, or send MIDI clock/MTC.
+ `midi_editor.rs:288` notes audition "needs a live MIDI input path".
+- **Impact:** MIDI production is limited to drawing notes and importing SMF.
+- **Estimate:** ≈ 20–35 h (device layer, realtime input to armed tracks and instruments with
+ timestamps, MIDI record/merge/loop, thru, output tracks, clock out).
+
+### G-03. Session interchange with Pro Tools
+
+- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media
+ Composer–compatible sessions. Our session format is `.scraft` JSON.
+- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in
+ or hand one back; post-production (picture editors deliver AAF) is blocked.
+- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn
+ from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether
+ black-box format study is in the clean-room rules; not included in the estimate.
+
+### G-04. Correctness bugs in "done" features
+
+Open on 2026-10-10 (several have PRs open):
+editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
+automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129);
+MIDI #87, #98 (PR #125); import #89 (PR #128); CLI #95, #96 (PR #126, #127); MCP #145 (PR #146);
+web #63, #64, #136; Clip List #4 (PR #75); plugin view #6; shortcuts #7, #41;
+launch #142; Linux #45, #107.
+- **Impact:** each one is small, but together they say "presence is not parity": an outside
+ contributor found seven in one sitting (#116–#122).
+- **Estimate:** ≈ 25–40 h at 0.5–1.5 h each, plus review of the open PRs.
+
+### G-05. Plugin hosting on macOS
+
+- **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed
+ build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views
+ (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP
+ GUIs; VST3 editors on Windows/Linux.
+- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it).
+
+### G-06. Memory-bound media
+
+- **Missing:** streaming sources from disk; recording to disk. Every source is decoded whole into
+ the `SourcePool`; takes accumulate in RAM until stop (PR #84 open).
+- **Impact:** hour-long multitrack sessions and long takes exhaust memory; a crash during a long
+ take loses it.
+- **Estimate:** ≈ 15–25 h.
+
+### G-07. Fidelity harness against Pro Tools
+
+- **Missing:** any measured comparison: null tests of bounces of the same material, fade curve
+ and pan-law checks, plugin frequency/dynamics responses, elastic-audio quality, automation
+ timing. Pro Tools is installed; it can be driven black-box with synthetic sessions (outputs kept
+ under `plan/`, never committed).
+- **Estimate:** ≈ 20–30 h for the harness and the first 20 scored cases; turns many "≈" in
+ target-app-parity.md into measurements.
+
+### G-08. Built-in plugins
+
+See [`plugin-parity.md`](plugin-parity.md): 26 processors + 2 instruments against Pro Tools'
+roughly 80 bundled processors and instruments; no fidelity checks; generic parameter UIs; EQ
+parametric request #139. ≈ 80–120 h.
+
+### G-09. Localization
+
+See [`localization-parity.md`](localization-parity.md): no catalog, every string literal in code;
+egui has no complex-script shaping or RTL; no CJK font fallback (PR #141 open). ≈ 70–110 h plus
+native-speaker review.
+
+### G-10. Control surfaces
+
+None: no HUI or Mackie Control, no EUCON (Avid-proprietary, needs Avid's SDK: owner decision),
+no generic MIDI controller mapping. Pro Tools users on S1/S3/S4/S6/Dock or HUI devices can't
+switch. ≈ 40–70 h for HUI/MCU plus a mapping layer; hardware needed to verify.
+
+### G-11. Sync
+
+No MTC/LTC chase or generation, no MIDI Beat Clock (`setup.midi_beat_clock` stores a flag only),
+no video reference or word clock awareness, no Satellite-style linking. ≈ 15–25 h after G-01/G-02.
+
+### G-12. Immersive delivery
+
+No Dolby Atmos renderer integration, ADM BWF import/export, re-renders, binaural monitoring,
+MPEG-H (new in Pro Tools 2026.4), surround/object automation. ≈ 50–80 h; Dolby's renderer and
+SDKs are licensed, so we'd write our own ADM and object renderer.
+
+### G-13. Plugin ecosystem depth
+
+No sidechain (key input) buses for any host; no ARA (Melodyne-style editing is a menu in Pro
+Tools); plugins run in-process, so a crashing plugin can take the session down. ≈ 30–50 h. AAX
+cannot be hosted (#109): Avid's AAX SDK licence and PACE signing; owner decision, likely never.
+
+### G-14. Platforms and CI
+
+Only `freebsd.yml` and `windows-arm64.yml` run on pull requests, and only for manifest paths;
+nothing runs `cargo xtask ci` on PRs. Windows and Linux have startup and rendering reports
+(#41, #45, #107, #142). ≈ 20–40 h (a PR CI matrix plus runtime smoke tests on three OSes).
+
+### G-15. Elastic Audio depth
+
+One warp algorithm; Pro Tools offers several per track (polyphonic, rhythmic, monophonic,
+varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing depth. ≈ 30–45 h.
+
+### G-16. Session management
+
+No session templates, track presets, workspace/sound browser with previews, batch rename (new in
+2026.4), disk-usage and relink depth. ≈ 25–40 h.
+
+### G-17. Video and post
+
+No HEVC, DNxHD/HR or AV1 decode; no bounce with video; no external timecode/9-pin; no iXML
+field-recorder conform. ≈ 30–50 h.
+
+### G-18. Speech-to-text
+
+Pro Tools transcribes dialogue to a searchable transcript (upgraded in 2026.4). We have a
+settings command only. Needs an openly licensed local speech model. ≈ 30–50 h.
+
+### G-19. UI feel
+
+See [`ui-parity.md`](ui-parity.md). ≈ 60–100 h.
+
+### G-20. MIDI and instrument depth
+
+MIDI learn, a sampler (PR #108), drum machine depth, notation editing (tuplets, lyrics,
+articulations, parts). ≈ 40–60 h.
+
+### G-21. Compressed export
+
+Bounce writes WAV/BWF/RF64, AIFF and FLAC; Pro Tools also writes MP3 and AAC/M4A in Bounce Mix.
+≈ 10–20 h (pure-Rust encoders).
+
+### G-22. Avid cloud services
+
+Collaboration, cloud projects, Sketch, Splice, Learn, sign-in: 11 of the 30 menu leaves still
+missing. Out of scope unless the owner decides otherwise.
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate |
+| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
new file mode 100644
index 0000000..2b3eadc
--- /dev/null
+++ b/docs/hardware-parity.md
@@ -0,0 +1,75 @@
+# Hardware parity
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is
+SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of
+any MIDI-device or control-surface code; Pro Tools' from its documentation and Setup menus.
+
+**Hardware: ~12 % ready** (estimated). **Performance: ~35 %** (estimated; nothing benchmarked
+against Pro Tools). Remaining ≈ 110–180 h for hardware, 40–70 h for performance.
+
+Legend: ✅ works · 🟡 partial · ❌ missing.
+
+## Audio interfaces
+
+| Feature | Pro Tools (macOS / Windows) | SoundCraft macOS | Windows | Linux | FreeBSD | Web |
+|---|---|---|---|---|---|---|
+| Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; no ASIO | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio |
+| Choose playback device | ✅ Playback Engine | ❌ default output only | ❌ | ❌ | ❌ | ❌ |
+| Choose input device | ✅ | ❌ default input only (#42) | ❌ | ❌ | ❌ | ❌ |
+| Buffer size (32–2048) | ✅ | ❌ fixed 512-sample mix block | ❌ | ❌ | ❌ | ❌ |
+| Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ |
+| Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ |
+| Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ |
+| Latency reporting and record offset compensation | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| Sample rate follows the session; resample when it can't | ✅ (switches device) | 🟡 resamples (input pitch bug fixed, #28) | 🟡 | 🟡 | 🟡 | 🟡 |
+| Avid HDX / HD Native DSP, Hybrid Engine DSP mode | ✅ | ❌ (out of reach: Avid hardware) | ❌ | ❌ | ❌ | ❌ |
+| Disk allocation / record to disk | ✅ | ❌ recording into RAM (PR #84) | ❌ | ❌ | ❌ | ❌ |
+
+## MIDI
+
+| Feature | Pro Tools | SoundCraft (all platforms) |
+|---|---|---|
+| MIDI input devices (keyboards, pads) | ✅ | ❌ no MIDI device layer at all (`setup.midi_input_devices` stores names) |
+| MIDI output to external gear | ✅ | ❌ |
+| MIDI Thru, input filter | ✅ | ❌ (stored flags only) |
+| MIDI Beat Clock out, MTC generate/chase | ✅ | ❌ |
+| MIDI learn / controller mapping | ✅ (via surfaces and plug-in maps) | ❌ |
+| Virtual MIDI keyboard | ✅ (MIDI Keyboard window) | 🟡 window exists, no live audition path |
+
+## Control surfaces
+
+| Feature | Pro Tools | SoundCraft |
+|---|---|---|
+| EUCON (S1/S3/S4/S6, Dock, Control app) | ✅ | ❌ Avid-proprietary SDK; owner decision |
+| HUI | ✅ | ❌ |
+| Mackie Control Universal | ✅ (via HUI emulation and personalities) | ❌ |
+| Generic MIDI controller personalities | ✅ | ❌ |
+
+## Sync and video hardware
+
+| Feature | Pro Tools | SoundCraft |
+|---|---|---|
+| LTC / MTC chase, 9-pin machine control | ✅ (Ultimate) | ❌ |
+| Word clock / video reference awareness | ✅ via interface | ❌ |
+| Video output to a second display or video card | ✅ | ❌ (video window only) |
+| Satellite linking of systems | ✅ | ❌ |
+
+## Performance
+
+| Feature | Pro Tools | SoundCraft | Evidence |
+|---|---|---|---|
+| Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` renders independent strips in parallel (rayon) |
+| Plugin delay compensation | ✅ | ✅ | |
+| Disk streaming of long sources | ✅ | ❌ | Whole files decoded into the `SourcePool` |
+| Track count (Ultimate: 2,048 voices) | ✅ | unmeasured | No benchmark exists; add `cargo xtask bench` |
+| Real-time audio thread discipline | ✅ | ✅ | No blocking/allocation in steady state, audio-thread-aware logger |
+| Freeze / commit to save CPU | ✅ | 🟡 | `track.commit` renders; `track.freeze` only sets a flag (no CPU saved) |
+| GPU-accelerated UI | — | ✅ (wgpu/glow via eframe) | llvmpipe X11 issue #107 |
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
new file mode 100644
index 0000000..fe832d4
--- /dev/null
+++ b/docs/localization-parity.md
@@ -0,0 +1,42 @@
+# Localization parity
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+SoundCraft is English only. There is no string catalog: every menu label, dialog and tooltip is a
+literal in Rust (`crates/engine/src/cmd/*`, `crates/ui-egui/src/*`). Pro Tools ships its UI in
+**8 languages** (bundle `.lproj` folders: English, German, Spanish, French, Japanese, Korean,
+Simplified Chinese, Traditional Chinese).
+
+**Localization: ~12 %** (measured: 1 of the target's 8 UI languages, and English is the only one
+complete). Remaining ≈ 70–110 h: a catalog and extraction pass (≈ 15–25 h), a font fallback for
+CJK/Devanagari/Arabic (PR #141 open), RTL layout and complex-script shaping (egui has neither:
+≈ 25–40 h, or ship Arabic/Hindi menus-only), then ≈ 3–5 h of agent translation per language, plus
+native-speaker review (human).
+
+Open work: PR #143 (French and Spanish, a Language setting), PR #141 (bundled multilingual font
+fallbacks), issue #67 (Ukrainian).
+
+| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Est. to full (h) | In Pro Tools |
+|---|---|---|---|---|---|---|---:|---|
+| English | en | all (source) | ✅ | Latin ✅ | — | **full** | 0 | ✅ |
+| Simplified Chinese | zh-Hans | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ |
+| Spanish | es | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ |
+| Hindi | hi | 0 (0 %) | ❌ | ❌ no Devanagari font or shaping | no | none | 8–15 | ❌ |
+| Arabic | ar | 0 (0 %) | ❌ | ❌ no RTL layout, no shaping | no | none | 15–25 | ❌ |
+| French | fr | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ |
+| Portuguese | pt | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ |
+| Indonesian | id | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ |
+| Japanese | ja | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ |
+| German | de | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ✅ |
+| Korean | ko | 0 (0 %) | ❌ | ❌ no Hangul font fallback | no | none | 6–10 | ✅ |
+| Vietnamese | vi | 0 (0 %) | ❌ | 🟡 Latin with stacked diacritics, untested | no | none | 3–5 | ❌ |
+
+Other languages shipped: **0**. (Pro Tools also ships Traditional Chinese, zh-Hant.)
+
+Per-language hours exclude the shared catalog and script work above.
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | major | Created: English only, no catalog; the twelve key languages at 0 %; Pro Tools' 8 languages listed from its bundle |
diff --git a/docs/mcp.md b/docs/mcp.md
index 2949871..d3ed9a9 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -22,7 +22,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|---|---|
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
| `execute` | Run one command with JSON params. |
-| `batch` | Run several commands in order (`keep_going` optional). |
+| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). |
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
diff --git a/docs/parity.md b/docs/parity-checklist.md
similarity index 94%
rename from docs/parity.md
rename to docs/parity-checklist.md
index 5572f35..229b783 100644
--- a/docs/parity.md
+++ b/docs/parity-checklist.md
@@ -1,6 +1,8 @@
# Parity report
-Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).
+> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree
+
+Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).
It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.
UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).
diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md
new file mode 100644
index 0000000..f59b598
--- /dev/null
+++ b/docs/plugin-parity.md
@@ -0,0 +1,61 @@
+# Plugin parity: built-in processors and plugin hosting
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
+cover the rest. This doc tracks both. Our plugins have our own names and are written from
+scratch (clean-room rule): Avid plugin names are not used here, only categories.
+
+**Built-in plugins and AudioSuite: breadth ~40 %, ready ~35 %** (80–120 h).
+**Third-party hosting: breadth ~70 %, ready ~45 %** (40–60 h). Both estimated.
+
+## Built-in processors (measured from `crates/dsp/src/plugins`)
+
+| Category | Pro Tools bundles (approx., by category) | SoundCraft (ids) | Gap |
+|---|---|---|---|
+| EQ | 1-band and 7-band EQ, channel strip, vintage EQ models | `eq_1band`, `eq_7band`, `channel_strip` | Vintage models, linear phase, dynamic EQ; parametric UI (#139) |
+| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) |
+| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor |
+| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth |
+| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation |
+| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
+| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
+| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
+| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth` | Sampler (PR #108 open), piano, organ, wavetable, content library |
+| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
+
+AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
+`crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview,
+handles, "clip by clip / entire selection" and multi-input modes beyond the basics; hosted
+plugins can't be used offline as AudioSuite yet.
+
+Fidelity: **unmeasured.** Nothing compares a SoundCraft processor's frequency response, gain
+reduction curve or reverb tail with the Pro Tools equivalent (G-07 in [`gaps.md`](gaps.md)).
+User report #152: a saved instrument level does not reach the bounce.
+
+## Third-party plugin hosting
+
+| Feature | Pro Tools | SoundCraft | Notes |
+|---|---|---|---|
+| Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never |
+| VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable |
+| Scan, vendor menus, instrument picker | ✅ | ✅ | #23 |
+| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | |
+| Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) |
+| State saved in sessions, presets | ✅ | ✅ | |
+| Create/destroy off the audio thread | ✅ | ✅ | |
+| Editors: CLAP | — | 🟡 floating windows; no embedded GUI | |
+| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing |
+| Editors: AU | — | ❌ | Cocoa view hosting missing |
+| Multiple editor pages | ✅ | 🟡 | #6 |
+| macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 |
+| Sidechain / key input | ✅ | ❌ | All three hosts |
+| ARA 2 (pitch/time editors in the timeline) | ✅ | ❌ | Menu leaves Track/Clip/Window ▸ ARA still missing |
+| Crash isolation | in-process (DSP on HDX) | in-process | Out-of-process hosting would beat Pro Tools |
+| Plugin delay reporting changes on the fly | ✅ | 🟡 | Aux insert latency bug #103 (PR #133) |
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |
diff --git a/docs/releasing.md b/docs/releasing.md
index e68b9a5..c277b79 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -50,6 +50,7 @@ web build and are checked, the signing jobs are refused, and no release is draft
| Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
+| Linux riscv64 | `soundcraft--linux-riscv64.tar.gz` (glibc 2.39+) | `ubuntu-24.04` (cross-compiled; CLI smoke-tested under QEMU) |
| AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
| Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..d8ea3f8
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,90 @@
+# SoundCraft roadmap: milestones and what's next
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha gate added; stage re-normalized to pre-alpha) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
+[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
+Hours are Opus 5.5 agent-hours.
+
+## Current focus
+
+1. **Merge or close the open bug-fix PRs** (#123–#134, #146, #148, #75) and fix the rest of the
+ correctness backlog (G-04). ≈ 15–25 h.
+2. **Audio interface basics** (G-01): device selection, buffer size, duplex stream, ASIO. ≈ 25–40 h.
+3. **MIDI devices** (G-02): input to armed tracks and instruments, output, thru, clock. ≈ 20–35 h.
+4. **macOS plugin hosting** (G-05): library-validation entitlement now (#153), then AU editors. ≈ 15–25 h.
+5. **A PR CI that runs `cargo xtask ci`** on Linux, macOS and Windows (G-14). ≈ 5–10 h.
+
+## Alpha gate
+
+The core workflows a Pro Tools professional does every day, checked end to end on macOS (the
+main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist
+(craftrules `standards/progress-docs.md`, Stages).
+
+| Workflow | Works end to end? | Evidence | Hours to pass |
+|---|---|---|---:|
+| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |
+| Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 |
+| Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — |
+| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) |
+| Bounce mix and stems; save and reopen the session | yes (rough) | WAV/BWF/AIFF/FLAC bounce, stems, `.scraft` save/reopen with plugin state; bugs #59, #103 in bus bounce and aux latency (PRs open) | — |
+| Exchange a session with Pro Tools or a picture editor | **no** | No `.ptx`, AAF or OMF import or export (#40, G-03) | 50–90 |
+
+**Result: pre-alpha.** Three workflows fail. Passing them is ~110–180 h, about 70 % of it
+parallelizable (interfaces, MIDI and AAF are independent crates).
+
+## Milestones
+
+Status: ✅ done · 🟡 in progress · ⬜ not started.
+
+| # | Milestone | Status | Remaining (h) |
+|---|---|---|---:|
+| M0–M4 | Workspace, gates, model, I/O, DSP, mix engine, command engine, Edit/Mix UI, playback, metering, plugins, bounce | ✅ | — |
+| M5 | Editing depth (tools, modes, playlists, fades, comping, clip groups) | ✅ (bugs open, G-04) | in G-04 |
+| M6 | Recording, punch, loop record, monitoring | 🟡 first cut; device choice and record-to-disk missing | 40–60 |
+| M7 | MIDI and notation editing | 🟡 | 50–80 |
+| M8 | CLI, control channel, MCP, agent acceptance test | ✅ | 3 |
+| M9 | AudioSuite breadth ✅, Elastic Audio 🟡, Beat Detective 🟡 | 🟡 | 40–60 |
+| M10 | Public releases on all platforms | ✅ v0.3.0 (2026-10-08) and v0.4.0 (2026-10-10) released for every platform | — |
+| M11 | Third-party plugin hosting (CLAP, VST3, AU), video, surround | 🟡 mostly done | 40–60 |
+| M12 | **Hardware**: audio interfaces, MIDI devices, sync, control surfaces | ⬜ | 110–180 |
+| M13 | **Interchange**: AAF/OMF import/export, disk streaming, record to disk | ⬜ | 65–115 |
+| M14 | **Fidelity harness** against Pro Tools (null tests, plugin responses) | ⬜ | 20–30 |
+| M15 | **Localization**: catalog, fonts/RTL, the 8 Pro Tools languages first | ⬜ (PRs #141, #143 open) | 70–110 |
+| M16 | Built-in plugin depth, sidechain, ARA | ⬜ | 110–170 |
+| M17 | Immersive delivery (ADM BWF, object renderer, binaural) and video depth | ⬜ | 80–130 |
+
+## To beta (~350–570 h, about 60–70 % parallelizable)
+
+Beta needs ~75 % ready for real work and no blocking gap in session interchange.
+
+| Step | Gaps | Est. (h) |
+|---|---|---:|
+| Correctness backlog and a PR CI | G-04, G-14 | 40–65 |
+| Audio interfaces, MIDI devices, sync | G-01, G-02, G-11 | 60–100 |
+| AAF/OMF interchange, disk streaming, record to disk | G-03, G-06 | 65–115 |
+| Plugin hosting completeness on every OS, sidechain | G-05, G-13 | 35–60 |
+| Fidelity harness and the fixes it finds | G-07 | 40–60 |
+| Built-in plugin essentials (EQ/dynamics/reverb/delay depth, sampler) | G-08 (half) | 40–60 |
+| UI feel pass: shortcuts, plugin windows, meters | G-19 (half) | 30–50 |
+| Localization of the 8 Pro Tools languages (catalog, CJK fonts) | G-09 (part) | 40–60 |
+
+Needs a human: hardware for G-01/G-02/G-10/G-11, native-speaker review, and owner decisions on
+AAX, `.ptx` and Avid cloud features.
+
+## After beta (to full parity, a further ~600–900 h)
+
+Control surfaces (G-10), immersive delivery (G-12), the rest of the built-in plugin catalogue
+(G-08), Elastic Audio depth (G-15), session templates and browser (G-16), video depth (G-17),
+speech-to-text (G-18), notation and instrument depth (G-20), compressed export (G-21), and the
+remaining languages.
+
+There is no `cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity`
+([`parity-checklist.md`](parity-checklist.md)).
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
+| 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
new file mode 100644
index 0000000..77d7be9
--- /dev/null
+++ b/docs/target-app-parity.md
@@ -0,0 +1,252 @@
+# Target-app parity: SoundCraft vs Avid Pro Tools
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95 % for each audience; full number confirmed as an additive weighted sum) · **Target:** Avid Pro Tools Ultimate 2026.4.1 (bundle 26.4.1.179, macOS)
+
+The authoritative assessment of how close SoundCraft is to Pro Tools. [`ROADMAP.md`](../ROADMAP.md)
+summarizes it, [`gaps.md`](gaps.md) itemizes every shortfall, and the per-dimension docs go deeper:
+[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md),
+[`hardware-parity.md`](hardware-parity.md), [`plugin-parity.md`](plugin-parity.md),
+[`localization-parity.md`](localization-parity.md). The generated menu checklist is
+[`parity-checklist.md`](parity-checklist.md).
+
+## Headline
+
+| Number | Value | Kind |
+|---|---|---|
+| Menu breadth (engine + UI layer) | **482 / 512 menu leaves (94.1 %)** | **measured**: the 512-leaf catalog `crates/engine/catalog/menus.txt` against the command registry; engine alone 397 / 512 (77.5 %) from `cargo xtask parity` ([`parity-checklist.md`](parity-checklist.md)); the 85 UI-layer matches re-counted on 2026-10-10 from `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`, `AUDIOSUITE`, `MENU_WINDOWS`, Mix Window Views) by script |
+| Feature breadth (menus plus everything not in a menu: plugins, hardware, formats, immersive, cloud) | **~70 %** | estimated (area table below, presence only) |
+| **Ready for real work** (whole of Pro Tools) | **~40 %** | estimated: written dimension weights × values = 40.6 % ([By dimension](#by-dimension)) |
+| **Mainstream practitioner** (a working tracking/mixing engineer) | **~36 %** | estimated: written area weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) |
+| **Essentials user** (making a song or podcast with the core features) | **~57 %** | estimated: written feature weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) |
+| Stage | **pre-alpha** (core-workflow gate fails) | see [Stage](#stage) |
+| Remaining effort to alpha | **~110–180 Opus 5.5 agent-hours** | estimated: the failing gate rows |
+| Remaining effort to beta | **~350–570 Opus 5.5 agent-hours** | estimated, calibrated (below) |
+| Remaining effort to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated, calibrated (below) |
+
+### Readiness by audience
+
+| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95 % | Work that dominates |
+|---|---:|---:|---|
+| Full target (ready for real work) | ~40 % | ~950–1,450 h (~60–70 % parallelizable) | Hardware (interfaces, MIDI, surfaces, sync), the built-in plugin catalogue, AAF/OMF and immersive delivery, localization, fidelity against Pro Tools |
+| Mainstream practitioner | ~36 % | ~380–600 h (~60 % parallelizable) | Interface recording and MIDI devices, AAF/OMF exchange, stock-plugin depth, the core-path bug backlog, interaction feel |
+| Essentials user | ~57 % | ~110–200 h (~70 % parallelizable) | Launch and platform fixes, an onboarding and user guide, stock-effect panels and presets, MP3/AAC export, editing and transport bugs |
+
+Each audience's hours are for its own areas only, so essentials ⊂ mainstream ⊂ full. Calibration
+is the same as for the full estimate ([Effort and calibration](#effort-and-calibration)): bug fixes
+at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, from the public-week PRs.
+- **Mainstream sum:** recording 40–60, MIDI 50–80, editing 40–60, mixing 35–50, automation 20–30,
+ stock plugins 80–120, AAF/OMF 50–90, compressed export 10–20, bug backlog 25–40, interaction
+ feel 30–50.
+- **Essentials sum:** default-device recording 10–15, transport 5–10, editing polish 15–25, effect
+ panels and presets 20–30, MP3/AAC export 10–20, built-in instrument 10–20, launch and platform
+ 15–25, onboarding and user guide 15–25, save fixes 5–10.
+
+The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %")
+measured feature *presence*. This pass measures readiness, and found new evidence that pulls the
+numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs
+in features counted as done), no MIDI hardware input or output at all, no audio device or buffer
+size selection, no localization, and a macOS release that cannot load signed third-party VST3s
+(#153). The estimates did not drift up; the scope of what is measured widened.
+
+## How this was measured
+
+- **The target.** `/Applications/Pro Tools.app` on the owner's Mac: Info.plist reports version
+ 26.4.1.179 (Pro Tools Ultimate 2026.4.1). Only bundle metadata was read (version,
+ `CFBundleDocumentTypes` for the file types it opens, the `.lproj` folder names for its UI
+ languages: en, de, es, fr, ja, ko, zh-Hans, zh-Hant). Nothing inside the bundle's resources,
+ plug-ins or code was read or copied (clean-room rule, AGENTS.md).
+- **The menu tree.** `plan/protools/menus-clean.txt` (observed black-box, local only) became the
+ committed 512-leaf catalog; `cargo xtask parity` and the UI layer's `engine.parity` score it.
+- **Avid's own documentation.** *What's New in Pro Tools 2026.4* (Track Pin, MPEG-H renderer,
+ Dolby headphone personalization, speech-to-text upgrades, batch file rename) and earlier
+ release notes for features outside menus (Dolby Atmos renderer integration, ADM BWF, Sketch,
+ cloud collaboration, Splice, ARA, HEAT, control surfaces, I/O setup).
+- **The code on origin/main (3bc2d92, 2026-10-10).** Read and counted with grep: ~70,900 lines of
+ Rust; 524 `#[test]` functions plus proptest suites; ~350 macro-registered engine command ids;
+ 26 built-in processors and 2 instruments in `crates/dsp/src/plugins`; audio I/O in
+ `crates/audio-io`; device I/O in `crates/playback` (cpal default output and default input only);
+ no MIDI device crate (no `midir`/CoreMIDI anywhere); no string catalog.
+- **Users.** The 37 open and 21 closed GitHub issues, and the 42 merged and 29 open pull
+ requests up to 2026-10-10, are the only field evidence. Nothing has been benchmarked against
+ Pro Tools.
+- **Not done:** running Pro Tools side by side on the same material (null tests of mixes, plugin
+ responses, fades, elastic audio). That is gap G-01 in [`gaps.md`](gaps.md).
+
+## By dimension
+
+Weights are how much each dimension decides whether a Pro Tools user (music production,
+post-production, tracking) can switch. "Ready" is depth, correctness and fidelity, not presence.
+
+| Dimension | Weight | Ready | Kind | Remaining (h) | Doc | Evidence |
+|---|---:|---:|---|---:|---|---|
+| Features | 45 % | **~45 %** | estimated | 520–810 | [area table](#feature-areas) | Menu breadth 94 % measured, but depth is thin: see the area table |
+| UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs |
+| File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export |
+| Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync |
+| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) |
+| Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks |
+| Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library |
+| Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) |
+| Localization | 2 % | **~12 %** | measured (catalog count) | 70–110 | [localization-parity.md](localization-parity.md) | English only, no string catalog; Pro Tools ships 8 UI languages; FR/ES PR #143 open |
+| AI features | 1 % | **~5 %** | estimated | 30–50 | [gaps.md](gaps.md) | Pro Tools has speech-to-text transcription (upgraded in 2026.4); we have only an audio-to-MIDI pitch tracker |
+| **Weighted** | 100 % | **~40 %** | | **~950–1,450** (overlaps removed) | | |
+
+Agent control is not weighted (Pro Tools has only its scripting SDK); SoundCraft is ahead there:
+every command is reachable from the CLI, the JSON control channel and MCP.
+
+## Mainstream practitioner and essentials user
+
+Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth
+average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and
+the evidence there; issue numbers are `storytold/soundcraft` GitHub issues.
+
+### Mainstream practitioner: ~36 %
+
+The typical Pro Tools professional is a music or post engineer who tracks through an audio
+interface, overdubs MIDI, edits, mixes with the stock plugins and bounces every week. This number
+leaves out third-party plugin hosting, Avid cloud services and AI, control surfaces and sync,
+immersive delivery, video, notation, and languages other than English.
+
+| Area (weekly use) | Weight | Depth |
+|---|---:|---:|
+| Recording and monitoring through an interface | 20 % | 40 % |
+| Editing (trim, fades, comping, Slip/Grid, nudge) | 22 % | 65 % |
+| Mixing and routing (faders, sends, busses, inserts, PDC) | 18 % | 60 % |
+| Automation | 10 % | 55 % |
+| Built-in plugins and AudioSuite | 12 % | 35 % |
+| MIDI recording, editing, instruments | 10 % | 30 % |
+| Bounce, save and reopen | 8 % | 60 % |
+| **Weighted depth** | 100 % | **50.6 %** |
+
+| Discount | Factor | Evidence |
+|---|---:|---|
+| Interaction fidelity | ×0.90 | Shortcut collisions and dead shortcuts (#140 Heal, #144 Mix/Edit vs zoom, #7 Export); Multitool handles don't adjust audio (#5); generic plugin parameter panels (#138, #139); clip deletion inconsistencies (#3, #4) |
+| Stability on real machines | ×0.92 | 26 open core-path bugs, e.g. bounce and latency (#59, #103), playback speed (#101), session saves (#86, #91), plus a launch failure (#142). Never-crash rules and property tests hold up: no crash reports on macOS since #16 |
+| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower |
+| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | |
+
+This comes out *below* the full number (~40 %), which the standard says is unusual. The
+mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the
+alpha-gate failures. The full number gets partial credit from areas this user doesn't weight:
+editing breadth, five platforms built, agent control.
+
+**User evidence** (GitHub, 2026-10-10; all 69 issues are from 28 outside reporters, none from
+maintainers):
+- **Praise:** 0 threads, and no "switched from Pro Tools" reports.
+- **Open issues:** of 48 open, 26 are core-path bugs (editing, bounce, playback, sessions,
+ shortcuts, plugin loading, recording monitoring), 7 are niche-path bugs (web build, CLI/MCP,
+ Linux fonts and llvmpipe), and 15 are requests.
+- **Requests:** several ask for other DAWs' feel: Ableton shortcuts and layout (#71, #72), an
+ FL/Ableton port (#38), Audition-style file editing (#81, #82). AAX (#109), VST2/LV2 (#30),
+ Ukrainian (#67).
+- **Closed:** 21.
+- **Contributions:** 12 outside contributors have merged PRs in the first week. That's
+ engagement, not yet use on real projects.
+
+### Essentials user: ~57 %
+
+A user who needs only the essentials: makes a song, demo or podcast, records through the computer's default
+microphone or interface, uses default settings, and exports a file to share.
+
+| Core feature | Weight | Depth | Notes |
+|---|---:|---:|---|
+| New session, open a session, demo | 10 % | 80 % | |
+| Import audio (drag and drop, WAV/MP3/AAC/FLAC) | 10 % | 80 % | Reads every common format |
+| Record from the default device | 12 % | 65 % | Works on the default input; forced monitoring can feed back (#42) |
+| Play, stop, loop, scroll, zoom | 10 % | 75 % | Half-speed after Stop (#101) |
+| Basic editing: select, cut/copy/paste, split, trim, move, fades | 18 % | 70 % | #3, #5 |
+| Undo/redo | 5 % | 90 % | Every command undoable |
+| Volume, pan, mute, solo | 10 % | 85 % | |
+| Add a stock effect (EQ, compressor, reverb) and pick a preset | 10 % | 60 % | Generic panels, few presets |
+| Draw MIDI notes for a built-in synth | 5 % | 50 % | Two built-in instruments |
+| Save and reopen | 5 % | 80 % | Web build can drop unsaved edits (#136) |
+| Export / bounce a file to share | 5 % | 60 % | WAV/AIFF/FLAC only, no MP3/AAC |
+| **Weighted depth** | 100 % | **72.4 %** | |
+
+| Discount | Factor | Evidence |
+|---|---:|---|
+| Launch and stability | ×0.90 | Doesn't launch for one user (#142), X11 llvmpipe failure (#107), Linux symbols as boxes (#45), Windows shortcuts (#41); macOS is solid |
+| Discoverability and UI clarity | ×0.90 | Pro Tools' dense layout with no onboarding or user guide; shortcut collisions; plugin windows are bare sliders |
+| Opening files people send them | ×0.97 | These users get WAV/MP3, which work; only Pro Tools sessions fail |
+| **Result** | 72.4 × 0.90 × 0.90 × 0.97 = **56.9 % ≈ 57 %** | |
+
+## Feature areas
+
+Weights by what Pro Tools users actually spend time on. **Breadth** = the feature exists;
+**Ready** = it works deeply and correctly enough for paid work.
+
+| Area | Weight | Breadth | Ready | Remaining (h) | What's there / what's missing |
+|---|---:|---:|---:|---:|---|
+| Recording and monitoring | 12 % | 75 % | **40 %** | 40–60 | Punch in/out, QuickPunch-style punch on the fly, pre/post-roll, loop record into playlists, input monitoring through the strip, autosave/recovery. Missing: input/output device choice, buffer size, duplex low-latency path, record straight to disk (#84 open), per-channel hardware input routing, TrackPunch/destructive punch beyond flags, monitoring opt-out (#42) |
+| Editing | 15 % | 92 % | **65 %** | 40–60 | Slip/Shuffle/Spot/Grid, every Edit menu command, fades/crossfades, playlists and comping, clip groups, nudge, Tab to transient, separate/heal/consolidate/strip silence. Open bugs: #3, #5 (Multitool handles), #140 (Heal shortcut), #86, #91, #92; no batch fade presets, no field-recorder workflows |
+| Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 |
+| Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 |
+| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 26 processors + 2 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) |
+| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) |
+| MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (Sampler PR #108 open), bug #98 |
+| Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) |
+| Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured |
+| Surround and immersive | 5 % | 55 % | **35 %** | 50–80 | Formats stereo → 9.1.6 and Ambisonics, surround panner, fold-down, multichannel bounce, object/bed routing. Missing: Dolby Atmos renderer integration and ADM BWF, MPEG-H (new in 2026.4), binaural/headphone monitoring, surround automation; bug #89 |
+| Video and post | 5 % | 60 % | **40 %** | 40–60 | Video track, H.264/ProRes/MJPEG decoders, sync offset, relinking, timecode rulers. Missing: HEVC/DNx/AV1, video bounce/export, timecode sync to external machines, speech-to-text transcript, field-recorder/iXML conform |
+| Session, file management, interchange | 10 % | 55 % | **35 %** | 60–100 | `.scraft` + Audio Files, Save Copy In, Import Session Data, clip groups, session text export, audio-health report. Missing: `.ptx` read/write, AAF/OMF (#40), session templates, track presets, workspace/Soundbase browser, disk streaming of long sources, batch rename; bugs #86, #91, #136 |
+| Collaboration, cloud, Sketch, Splice | 2 % | 0 % | **0 %** | not estimated | Avid services; out of scope unless the owner decides otherwise |
+| Agent control (CLI, control channel, MCP) | 1 % | 100 % | **100 %** | 3 | Ahead of Pro Tools' scripting SDK; one open bug (#145) |
+| **Weighted** | 100 % | **~70 %** | **~45 %** | **~520–810** | |
+
+## Stage
+
+**Pre-alpha.** Readiness (~40 %) is at the alpha bar, but the core-workflow gate
+([`roadmap.md`](roadmap.md#alpha-gate)) fails three of six Pro Tools workflows on macOS, the main
+platform:
+
+- Tracking from a chosen interface at a chosen buffer size: **no**. Only the default devices are
+ used, the mix runs in fixed 512-sample blocks, input and output are separate streams, and
+ takes are held in RAM.
+- MIDI recording: **no**. There is no MIDI device layer.
+- Session interchange with Pro Tools or picture editors: **no**. No AAF/OMF/`.ptx`.
+
+Editing, mixing with plugins, bouncing, and saving and reopening pass (with known bugs).
+
+**Distance to alpha: ~5 points of readiness and ~110–180 agent-hours** (G-01, G-02, G-03, part
+of G-06, and the #153 entitlement). **Distance to beta: ~35 points and ~350–570 agent-hours**
+(beta also needs ~75 % ready). The beta plan is in [`roadmap.md`](roadmap.md).
+
+## Effort and calibration
+
+Hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, including
+tests and verification.
+
+**Calibrated against this repo's history** (`git log`, `gh pr list`):
+
+- The 10-05 bulk commits (`first commit` through `Video track, Audio Unit hosting…`, ~70k lines)
+ carry batch-commit times, not authoring times, so they don't calibrate.
+- The public week (2026-10-07 → 10-10, 42 merged PRs) does. Focused bug fixes with a regression
+ test, +20 to +120 lines (#116–#122, #25, #28, #37, #61), took **~0.5–1.5 h** each. Small
+ features of +200 to +1,000 lines (#32 light theme +516, #11 log file +981, #8 credits +666, #23
+ plugin menus +259, #76 clip-gain fader +198) took **~2–5 h**: about **200–300 tested lines per
+ agent-hour**.
+- Areas were sized by the code they need at that rate (for example, MIDI device I/O plus MIDI
+ recording ≈ 3–5k lines ≈ 12–25 h), plus a 1.3–1.5× tail factor for verification against Pro
+ Tools behaviour, as FilmCraft observed for its long tail.
+
+**Parallelism.** About 60–70 % of the work parallelizes across 4–5 agents by crate (dsp plugins,
+hardware/playback, file interchange, UI, localization). The audio-thread and hardware work
+serializes more, because it needs hands-on testing.
+
+**Needs a human:**
+- Owner decisions: AAX (requires Avid's SDK licence and PACE signing; probably never), `.ptx`
+ reverse engineering (clean-room implications), Avid cloud features (out of scope by default).
+- Hardware we don't have in CI: multichannel interfaces, ASIO drivers, control surfaces (HUI/MCU,
+ EUCON is Avid-proprietary), MIDI keyboards, sync generators.
+- Native-speaker review for every language beyond English.
+- Ears: a mix engineer's A/B of plugins, fades and elastic audio.
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95 % for full (950–1,450 h), mainstream (380–600 h) and essentials (110–200 h). The full number is already the additive weighted sum over the written dimension table (40.6 %); method unchanged |
+| 2026-10-10 | minor | Added the mainstream-practitioner (~36 %) and essentials-user (~57 %) numbers with written weights, discounts and user evidence. Checked the full ~40 %: it is the written dimension weights × values (40.6 %), so it doesn't move |
+| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; alpha distance ~110–180 h |
+| 2026-10-10 | major | Full re-measure against Pro Tools Ultimate 2026.4.1: two numbers (breadth ~70 %, ready ~40 %), dimension and area tables with hours, stage alpha; hours re-calibrated from PR history (110–150 h → 950–1,450 h to full parity: the old figure counted presence only) |
+| 2026-10-07 | major | Area table and "~65 % overall, 110–150 h" estimate (then in ROADMAP.md) |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
new file mode 100644
index 0000000..80ff25b
--- /dev/null
+++ b/docs/ui-parity.md
@@ -0,0 +1,67 @@
+# UI parity
+
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1
+
+How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
+windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
+registry (shortcuts), `crates/ui-egui/src` (windows, gestures) and user issues; compared with
+reference screenshots kept locally under `plan/protools/screenshots/` (16, never committed).
+
+**UI/UX fidelity: ~55 % ready** (estimated). Remaining ≈ 60–100 h.
+
+Legend: ✅ matches · 🟡 partial · ❌ missing.
+
+## Tools and modes
+
+| Feature | Status | Notes |
+|---|---|---|
+| Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) |
+| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present |
+| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples |
+| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial |
+| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | |
+| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured |
+
+## Keyboard and mouse
+
+| Feature | Status | Notes |
+|---|---|---|
+| Default shortcuts | 🟡 146 bound (measured: distinct `Some("…")` shortcuts in the registry) | Pro Tools' default set is several hundred incl. numeric keypad and focus keys. Collisions: Mix/Edit toggle vs zoom (#144), Heal (#140), Export (#7) |
+| Ctrl as Cmd on Windows/Linux | ✅ since #15, #41 still open in some builds | #62 shows Ctrl in labels |
+| Numeric keypad transport and memory-location recall | 🟡 | Classic / Transport / Shuttle modes not all present |
+| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; not across every control |
+| Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 |
+| Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | |
+| Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4) |
+
+## Windows and panels
+
+| Window | Status | Notes |
+|---|---|---|
+| Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) |
+| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Delay Compensation view and HEAT missing |
+| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow |
+| Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | |
+| Workspace / sound browser with previews | ❌ | G-16 |
+| I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) |
+| Preferences | 🟡 | Many settings stored, fewer honoured |
+| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
+| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
+| Command palette (Search) | ✅ | Over every command |
+
+## Look and meters
+
+| Feature | Status | Notes |
+|---|---|---|
+| Dark theme close to Pro Tools' | ✅ | Light theme (#32) and live system appearance (#85) beyond Pro Tools |
+| Interface scaling | 🟡 | Slider in PR #147 |
+| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial |
+| Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | |
+| Fader resolution, fine adjust, numeric entry | 🟡 | |
+| macOS system menu bar | ✅ | #10 |
+
+## Revision history
+
+| Date | Change | Summary |
+|---|---|---|
+| 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 |
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 65e9a60..a949dba 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Build and package SoundCraft for Linux ( is x86_64 or aarch64):
+# Build and package SoundCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's
# $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake)
@@ -13,6 +13,10 @@
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
# zsyncmake (the zsync package) for the AppImage's .zsync.
+#
+# Cross-compiling (CI's linux-riscv64 job, tar.gz only): set CROSS_ARCH (riscv64), CROSS_TARGET
+# (the Rust target), CROSS_COMPILE (the binutils prefix, for strip) and optionally EMULATOR
+# (e.g. qemu-riscv64) to smoke-test the CLI.
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
@@ -25,15 +29,16 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
- -h | --help) sed -n '2,17p' "$0"; exit 0 ;;
+ -h | --help) sed -n '2,19p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
-ARCH="$(uname -m)"
+ARCH="${CROSS_ARCH:-$(uname -m)}"
case "$ARCH" in
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
+ riscv64) DEB_ARCH=riscv64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
export SOUNDCRAFT_MAINTAINER="${SOUNDCRAFT_MAINTAINER:-SoundCraft maintainers }"
@@ -42,9 +47,13 @@ BASENAME="soundcraft-$VERSION-linux-$ARCH"
echo "==> SoundCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
- (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli)
+ if [ -n "${CROSS_TARGET:-}" ]; then
+ (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli --target "$CROSS_TARGET")
+ else
+ (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli)
+ fi
fi
-BIN="$CARGO_TARGET_DIR/release"
+BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
@@ -52,7 +61,7 @@ rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/soundcraft" "$STAGE/usr/bin/soundcraft"
install -Dm755 "$BIN/soundcraft-cli" "$STAGE/usr/bin/soundcraft-cli"
-strip "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true
+"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
@@ -135,6 +144,10 @@ if has appimage; then
fi
fi
-"$STAGE/usr/bin/soundcraft-cli" --version
+if [ -z "${CROSS_TARGET:-}" ]; then
+ "$STAGE/usr/bin/soundcraft-cli" --version
+elif [ -n "${EMULATOR:-}" ]; then
+ "$EMULATOR" "$STAGE/usr/bin/soundcraft-cli" --version
+fi
echo "==> done"
ls -lh "$DIST"
diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md
index 5bbc169..0cacbb7 100644
--- a/packaging/macos/dmg/README.md
+++ b/packaging/macos/dmg/README.md
@@ -2,8 +2,8 @@
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
-generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
-Finder scripting on CI, nothing extra installed in the signing job).
+generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder
+scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index a898e3d..8208aac 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
+# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
+# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
+xattr -cr "$APP"
+
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft"
@@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications"
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
-rm -f "$DMG" "$WORK/raw.dmg"
-# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
-# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
-# The volume name has no version: .DS_Store finds the background through an alias that includes it.
-hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
-hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
-rm -f "$WORK/raw.dmg"
+rm -f "$DMG"
+# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
+# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
+# similar detritus" on the app in the image and on a copy installed from it (#176). `create`
+# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so
+# it gets a few tries. The volume name has no version: .DS_Store finds the background through an
+# alias that includes it.
+# Usage: hdiutil_retry ; tries five times with growing pauses.
+hdiutil_retry() {
+ local what="$1" attempt
+ shift
+ for attempt in 1 2 3 4 5; do
+ if hdiutil "$@"; then
+ return 0
+ fi
+ [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
+ warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
+ sleep $((attempt * 10))
+ done
+}
+hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
+# The app users get must still pass --strict: inside the finished image and after a ditto copy out
+# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression).
+MOUNT="$WORK/dmg-check"
+COPY="$WORK/dmg-copy"
+rm -rf "$MOUNT" "$COPY"
+mkdir -p "$MOUNT" "$COPY"
+hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null
+dmg_ok=1
+codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0
+if [ "$dmg_ok" = 1 ]; then
+ { ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" &&
+ codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0
+fi
+if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then
+ spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0
+fi
+[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true
+hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0
+rm -rf "$COPY"
+if [ "$dmg_ok" != 1 ]; then
+ echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2
+ exit 1
+fi
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 9ccb8fe..8727c05 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -35,7 +35,7 @@ const LAYERS: &[(&str, u8)] = &[
];
/// Standalone format crates: no workspace dependencies at all (publishable on their own).
const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"];
-const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"];
+const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"];
const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"];
fn layers() -> Result<(), String> {
@@ -113,7 +113,7 @@ fn assets() -> Result<(), String> {
}
fn parity() -> Result<(), String> {
- cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity.md"])
+ cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity-checklist.md"])
}
/// Crates that must compile for the web.