Merge remote-tracking branch 'origin/main' into menu-fixes

This commit is contained in:
Brandon Thomas committed 2026-10-11 20:14:54 -04:00
commit cc36a3eba6
66 files changed
+4482 -932

No files matched your search

+10 -28
View File
@@ -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
+2 -46
View File
@@ -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 '<Icon Id="[^"]*"' packaging/windows/soundcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.soundcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.soundcraft.metainfo.xml.in >"$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
+22 -357
View File
@@ -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<version>". 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 }}
+11 -73
View File
@@ -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
+4 -4
View File
@@ -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)
Generated
+30 -18
View File
@@ -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"
@@ -3403,12 +3412,14 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.4.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",
@@ -3419,7 +3430,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3429,7 +3440,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3441,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"serde_json",
@@ -3451,7 +3462,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3463,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3474,7 +3485,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3485,7 +3496,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3506,7 +3517,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3516,7 +3527,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"log",
"rayon",
@@ -3532,7 +3543,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3545,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"cpal",
"log",
@@ -3558,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3568,8 +3579,9 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"image",
@@ -3593,7 +3605,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"image",
"proptest",
@@ -3604,7 +3616,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"libloading",
"log",
@@ -3619,7 +3631,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@@ -5166,7 +5178,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -41,6 +41,7 @@ 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"
+19 -5
View File
@@ -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-<ver>-linux-x86_64.rpm` | `soundcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `soundcraft-<ver>-linux-x86_64.tar.gz` | `soundcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V (riscv64): `soundcraft-<ver>-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.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
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.
+128 -55
View File
@@ -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 |
+57 -15
View File
@@ -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());
+124
View File
@@ -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);
}
+2
View File
@@ -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"] }
+244
View File
@@ -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<PathBuf> {
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<OsString>) -> Option<PathBuf> {
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<Option<soundcraft_ui_egui::UiState>, String> {
read_ui(path, true)
}
fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<soundcraft_ui_egui::UiState>, 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::<soundcraft_ui_egui::docking::Workspace>(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::<soundcraft_ui_egui::UiState>(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<PathBuf> {
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)));
}
}
+51 -5
View File
@@ -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<Backends>) {
/// 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<Backends>, compiler_override: Option<Dx12Compiler>) {
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<Backends>
// 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>) -> 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()] {
+18 -26
View File
@@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
@@ -122,24 +114,24 @@ fn load_prefs() -> Option<UiState> {
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<String> = 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,
+12 -4
View File
@@ -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
{
+39 -26
View File
@@ -2,36 +2,36 @@
"schema": 1,
"repo": "storytold/soundcraft",
"branch": "main",
"head": "caa4872df2aa81a21e401071d2e0d09002658e9e",
"head_date": "2026-10-10T00:50:08Z",
"commits": 98,
"head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685",
"head_date": "2026-10-10T18:08:49Z",
"commits": 111,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 5,
"commits": 56,
"lines_added": 79132,
"lines_deleted": 1983,
"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-10T00:50:08Z"
"last_commit": "2026-10-10T18:08:49Z"
},
{
"login": "reoniazx",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 7,
"lines_added": 453,
"lines_deleted": 62,
"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-09T15:12:12Z"
"last_commit": "2026-10-10T05:15:18Z"
},
{
"login": "switch87",
@@ -115,14 +115,14 @@
"login": "vbasky",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 4,
"lines_added": 829,
"lines_deleted": 137,
"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-09T22:37:04Z"
"last_commit": "2026-10-10T05:22:24Z"
},
{
"login": "DWAA1660",
@@ -167,14 +167,27 @@
"login": "grovebit",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 198,
"lines_deleted": 5,
"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-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": [
@@ -183,9 +196,9 @@
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 71,
"lines_added": 80906,
"lines_deleted": 2305
"commits": 73,
"lines_added": 82279,
"lines_deleted": 2544
},
{
"id": "anthropic/claude-haiku/5.5",
+5
View File
@@ -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
+14 -3
View File
@@ -127,11 +127,20 @@ impl<B: Backend> Server<B> {
}
"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<B: Backend> Server<B> {
}
}
}
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()),
+36
View File
@@ -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);
}
+2 -1
View File
@@ -71,8 +71,9 @@ unsafe fn read_cstr(p: *const c_char) -> Option<String> {
}
/// 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<u8> = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect();
let bytes: Vec<u8> = 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()
}
+46 -2
View File
@@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
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::<String>()).collect::<Vec<_>>().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}");
}
}
+65
View File
@@ -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<f32>], b: &[Vec<f32>]) -> 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<f32> = (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<f32> = (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);
}
}
+199 -6
View File
@@ -27,9 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
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<CommandSpec> {
}),
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<Option<TrackId>> {
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<Option<soundcraft_model::BusId>> {
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<bool> {
match str_param(p, "fold_down").map(str::trim) {
@@ -260,7 +270,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
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", &params).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,7 +448,6 @@ 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();
@@ -428,4 +485,140 @@ mod tests {
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<Vec<f32>> = (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(&copy_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);
}
}
+193 -7
View File
@@ -534,25 +534,95 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
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<Value> {
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<Value> {
}
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::<f32>() / 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<f32> = (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<f32> {
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");
}
}
}
+98 -13
View File
@@ -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<Vec<u8>> {
/// 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<Vec<u8>> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
@@ -191,16 +210,25 @@ pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
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<u8>, (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<BusId>,
) -> Result<(Vec<u8>, (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<Vec<f32>>, 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<BusId>) -> (Vec<Vec<f32>>, 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)
}
@@ -471,6 +525,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
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<usize> {
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)> {
@@ -480,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<bool> =
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 };
@@ -499,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 {
@@ -622,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)]
+2 -4
View File
@@ -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 => {
+18
View File
@@ -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() };
+206 -24
View File
@@ -293,6 +293,11 @@ pub struct MixEngine {
order: Vec<usize>,
/// 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<Vec<f32>>,
/// 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<Box<dyn Plugin>>,
/// 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<f32>]) {
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<Vec<f32>> {
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<Vec<f32>> {
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<f32>]) -> Vec<Vec<f32>> {
@@ -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<usize> {
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();
+92 -18
View File
@@ -86,7 +86,6 @@ struct AudioState {
playing: bool,
end: Option<Samples>,
looped: Option<Range>,
block: Vec<Vec<f32>>,
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
pending: Vec<Vec<f32>>,
/// 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");
}
}
+2 -1
View File
@@ -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
+475
View File
@@ -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, String> {
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<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
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<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
}
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<Panel>) -> 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<Panel> = 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<bool> =
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<Value, String> {
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::<Panel>(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<Value, String> {
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) }
}
+13 -20
View File
@@ -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::<f32>();
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<String> = app.engine.session().edit.rulers.clone();
+3 -2
View File
@@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option<Value> {
}
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.
+88 -3
View File
@@ -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<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
/// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
meter_snapshot: soundcraft_playback::MeterSnapshot,
pub gesture: Option<Gesture>,
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<bool>,
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
pub(crate) docking_context: Option<egui::Context>,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
pub quit_requested: bool,
@@ -239,6 +249,7 @@ pub struct SoundApp {
last_write_at: Samples,
pub frame_ms: f32,
last_frame: Option<f64>,
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() {
@@ -716,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);
@@ -740,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);
}
@@ -796,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);
@@ -970,6 +1006,16 @@ mod tests {
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() {
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
@@ -988,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");
}
}
+22 -2
View File
@@ -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+=")),
@@ -195,6 +197,7 @@ pub fn tree() -> Vec<MenuNode> {
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);
}
@@ -279,7 +282,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
/// 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,
};
@@ -444,6 +451,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<Result<Value, String>> {
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));
}
@@ -511,6 +521,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
}
"window.configurations" => 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();
@@ -525,6 +538,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
json!({"configurations": app.ui.configurations.len()})
}
"window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => {
if let Err(error) = crate::docking::arrange(app) {
app.ui.status = format!("Panel windows could not be arranged: {error}");
}
app.arrange_request = true;
json!({})
}
@@ -584,8 +600,12 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
obj.insert(k.clone(), v.clone());
}
}
match serde_json::from_value(cur) {
match serde_json::from_value::<crate::UiState>(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})
}
+27 -27
View File
@@ -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<TrackId> = 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<TrackId> = 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);
+62 -78
View File
@@ -168,9 +168,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);
@@ -277,92 +275,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::<String>(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::<String>(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) {
+2
View File
@@ -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;
}
+9 -1
View File
@@ -6,6 +6,13 @@ 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);
/// 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)]
#[serde(rename_all = "lowercase")]
@@ -443,7 +450,8 @@ mod tests {
#[test]
fn application_frames_follow_system_appearance_and_preserve_manual_palettes() {
// Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME.
// 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<egui::Theme>, light: bool) {
let mode = app.ui.theme;
let colors = |app: &SoundApp| {
+237 -27
View File
@@ -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<String, serde_json::Value>| {
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"
);
}
}
}
}
}
+9 -4
View File
@@ -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::<crate::UiState>(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);
+47
View File
@@ -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<bool>) {
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);
}
+159
View File
@@ -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<UiState>, 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();
}
+76 -3
View File
@@ -13,7 +13,7 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
}
fn panel(ctx: &egui::Context) -> Rect {
egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect
ctx.data(|data| data.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap()
}
fn button(pos: Pos2, pressed: bool) -> Event {
@@ -39,8 +39,8 @@ fn check_resize(with_clip: bool) {
// 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() + 1.0);
let end = pos2(start.x, before.bottom() - height);
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 {
@@ -65,6 +65,12 @@ fn check_resize(with_clip: bool) {
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);
}
@@ -77,3 +83,70 @@ fn midi_panel_resizes_with_a_selected_clip() {
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);
}
@@ -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(&param.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);
}
+217
View File
@@ -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<Response>,
}
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<Event>) -> 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<egui::Rect>| {
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();
}
}
+21 -1
View File
@@ -1,7 +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();
@@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
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:?}");
}
}
+83
View File
@@ -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();
}
}
}
}
+29 -2
View File
@@ -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 |
+31
View File
@@ -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 <port>` |
## 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:
@@ -72,3 +84,22 @@ the native or web integration at launch and on later frames, while the saved cho
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.
+83
View File
@@ -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 |
+200
View File
@@ -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 |
+75
View File
@@ -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 |
+42
View File
@@ -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 |
+1 -1
View File
@@ -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. |
+3 -1
View File
@@ -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).
+61
View File
@@ -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 |
+1
View File
@@ -50,6 +50,7 @@ web build and are checked, the signing jobs are refused, and no release is draft
| Windows 11 ARM64 | `soundcraft-<v>-windows-arm64.msi`, `soundcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) |
| Linux x86_64 | `soundcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `soundcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux riscv64 | `soundcraft-<v>-linux-riscv64.tar.gz` (glibc 2.39+) | `ubuntu-24.04` (cross-compiled; CLI smoke-tested under QEMU) |
| AppImage updates | `soundcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage |
| Flatpak x86_64, aarch64 | `soundcraft-<v>-linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `soundcraft-<v>-freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` |
+90
View File
@@ -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 |
+252
View File
@@ -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) |
+67
View File
@@ -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 |
+20 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package SoundCraft for Linux (<arch> is x86_64 or aarch64):
# Build and package SoundCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/soundcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/soundcraft-<version>-linux-<arch>.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 <soundcraft@storyteller.ai>}"
@@ -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"
+2 -2
View File
@@ -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 |
|---|---|
+48 -7
View File
@@ -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 <what> <hdiutil args…>; 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"
+2 -2
View File
@@ -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.