mirror of
https://github.com/aphrody-labs/soundcraft.git
synced 2026-10-13 19:26:53 +00:00
Merge remote-tracking branch 'origin/main' into clear-clips
This commit is contained in:
commit
7c26ea4f44
73 files changed
+5173
-1051
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
-55
@@ -782,6 +782,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -837,23 +846,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "dark-light"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"objc2 0.6.5",
|
||||
"objc2-foundation 0.3.2",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -1319,15 +1311,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -3429,12 +3412,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-engine",
|
||||
@@ -3445,7 +3430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3455,7 +3440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3467,7 +3452,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -3477,7 +3462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
@@ -3489,7 +3474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
@@ -3500,7 +3485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3511,7 +3496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3532,7 +3517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3542,7 +3527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3558,7 +3543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3571,7 +3556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -3584,7 +3569,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3594,9 +3579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"dark-light",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
@@ -3620,7 +3605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
@@ -3631,7 +3616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
@@ -3646,7 +3631,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5106,16 +5091,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
@@ -5203,7 +5178,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -41,10 +41,10 @@ rustfft = "6"
|
||||
cpal = "0.16"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
dark-light = "3"
|
||||
rfd = "0.17"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+189
-11
@@ -2,23 +2,192 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/soundcraft",
|
||||
"branch": "main",
|
||||
"head": "3597f895cc49ad795650839c1eeeeac0f88e2aa9",
|
||||
"head_date": "2026-10-07T18:45:52Z",
|
||||
"commits": 49,
|
||||
"head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685",
|
||||
"head_date": "2026-10-10T18:08:49Z",
|
||||
"commits": 111,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 49,
|
||||
"lines_added": 78099,
|
||||
"lines_deleted": 1928,
|
||||
"prs": 6,
|
||||
"commits": 58,
|
||||
"lines_added": 80505,
|
||||
"lines_deleted": 2222,
|
||||
"binary_added": 23,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T00:08:04Z",
|
||||
"last_commit": "2026-10-07T18:45:52Z"
|
||||
"last_commit": "2026-10-10T18:08:49Z"
|
||||
},
|
||||
{
|
||||
"login": "reoniazx",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 9,
|
||||
"lines_added": 649,
|
||||
"lines_deleted": 64,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T06:10:03Z",
|
||||
"last_commit": "2026-10-10T05:15:18Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 981,
|
||||
"lines_deleted": 18,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T10:25:49Z",
|
||||
"last_commit": "2026-10-08T10:27:07Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 3,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 62,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:13:25Z",
|
||||
"last_commit": "2026-10-08T14:02:25Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 243,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "ebbicott",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 5,
|
||||
"lines_added": 791,
|
||||
"lines_deleted": 97,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T17:45:32Z",
|
||||
"last_commit": "2026-10-08T19:40:37Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 6,
|
||||
"commits": 7,
|
||||
"lines_added": 274,
|
||||
"lines_deleted": 35,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T18:06:42Z",
|
||||
"last_commit": "2026-10-09T19:25:52Z"
|
||||
},
|
||||
{
|
||||
"login": "Garygunn94",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 7,
|
||||
"lines_added": 386,
|
||||
"lines_deleted": 38,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:24:47Z",
|
||||
"last_commit": "2026-10-09T15:05:24Z"
|
||||
},
|
||||
{
|
||||
"login": "vbasky",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 11,
|
||||
"lines_added": 1039,
|
||||
"lines_deleted": 174,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:12:26Z",
|
||||
"last_commit": "2026-10-10T05:22:24Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 67,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:58:22Z",
|
||||
"last_commit": "2026-10-09T00:58:22Z"
|
||||
},
|
||||
{
|
||||
"login": "hamada147",
|
||||
"people_entry": true,
|
||||
"github_name": "Ahmed Moussa",
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 27,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T08:49:05Z",
|
||||
"last_commit": "2026-10-09T08:49:05Z"
|
||||
},
|
||||
{
|
||||
"login": "salignatmoandal",
|
||||
"people_entry": true,
|
||||
"github_name": "where's Mawen?",
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 119,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T19:03:36Z",
|
||||
"last_commit": "2026-10-09T19:24:17Z"
|
||||
},
|
||||
{
|
||||
"login": "grovebit",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 218,
|
||||
"lines_deleted": 15,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:45:39Z",
|
||||
"last_commit": "2026-10-10T01:34:26Z"
|
||||
},
|
||||
{
|
||||
"login": "coygeek",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 125,
|
||||
"lines_deleted": 91,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T01:28:07Z",
|
||||
"last_commit": "2026-10-10T01:28:07Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
@@ -27,9 +196,18 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 47,
|
||||
"lines_added": 78091,
|
||||
"lines_deleted": 1927
|
||||
"commits": 73,
|
||||
"lines_added": 82279,
|
||||
"lines_deleted": 2544
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-haiku/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Haiku",
|
||||
"version": "5.5",
|
||||
"commits": 5,
|
||||
"lines_added": 368,
|
||||
"lines_deleted": 35
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
p,
|
||||
"edit.clear_clip_gain"
|
||||
)),
|
||||
cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
|
||||
cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{tracks?, start?, end?}", has_range, |e, p| {
|
||||
super::edit_more::cut_all_automation(e, p)
|
||||
}),
|
||||
cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
|
||||
cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
|
||||
e,
|
||||
|
||||
@@ -277,6 +277,10 @@ fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result<Range> {
|
||||
if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) }
|
||||
}
|
||||
|
||||
pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
auto_copy(e, p, Lanes::All, true, "edit.cut_all_automation")
|
||||
}
|
||||
|
||||
fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result<Value> {
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let r = nonempty_range(e, p, id)?;
|
||||
@@ -663,8 +667,10 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result<Value> {
|
||||
let mut moved = 0;
|
||||
for (id, _) in order {
|
||||
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let others =
|
||||
s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id && !ids.contains(&x.id)).cloned().collect::<Vec<_>>()).unwrap_or_default();
|
||||
// Selected neighbours are real destinations. Ignoring them made Snap to Next
|
||||
// pile every selected clip onto the same later clip, and Snap to Previous
|
||||
// leave the gap between the selected clips in place.
|
||||
let others = s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id).cloned().collect::<Vec<_>>()).unwrap_or_default();
|
||||
let delta = if next {
|
||||
others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end())
|
||||
} else {
|
||||
@@ -982,6 +988,34 @@ mod tests {
|
||||
assert_eq!(starts(&e, t), vec![0, 900, 1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_all_automation_can_be_pasted() {
|
||||
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 100, "value": -6.0})).unwrap();
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 400, "value": -12.0})).unwrap();
|
||||
let cut = e.execute("edit.cut_all_automation", &json!({"start": 50, "end": 250})).unwrap();
|
||||
assert!(cut["lanes"].as_u64().unwrap() >= 1, "{cut}");
|
||||
assert!(e.clipboard.automation.iter().any(|lanes| !lanes.is_empty()));
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
|
||||
assert!(lane.points.iter().all(|p| p.at != 100), "the cut point is gone: {:?}", lane.points);
|
||||
e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap();
|
||||
let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
|
||||
assert!(vol.points.iter().any(|p| p.at >= 1000 && (p.value + 6.0).abs() < 1e-2), "paste wrote nothing at -6 dB: {:?}", vol.points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_selected_neighbours_keeps_both_clips() {
|
||||
let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
|
||||
e.execute("edit.snap_next", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
|
||||
assert_eq!(starts(&e, t), vec![800, 900, 1000]);
|
||||
|
||||
let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
|
||||
e.execute("edit.snap_previous", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
|
||||
assert_eq!(starts(&e, t), vec![0, 100, 1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_paste_automation_only() {
|
||||
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
|
||||
|
||||
@@ -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", ¶ms).unwrap();
|
||||
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
|
||||
(std::fs::read(&path).unwrap(), peak)
|
||||
};
|
||||
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
|
||||
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
|
||||
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
|
||||
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
|
||||
assert_eq!(main_bytes, named_main);
|
||||
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
|
||||
assert!(main_peak > -20.0, "{main_peak}");
|
||||
assert_ne!(stem_bytes, main_bytes);
|
||||
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
|
||||
assert_ne!(empty_bytes, main_bytes);
|
||||
assert!(empty_peak < -100.0, "{empty_peak}");
|
||||
let missing = dir.join("missing.wav");
|
||||
let err = e
|
||||
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("unknown bus"), "{err}");
|
||||
assert!(!missing.exists());
|
||||
assert!(std::fs::remove_dir_all(&dir).is_ok());
|
||||
}
|
||||
|
||||
fn remove_media(dir: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = entry.path();
|
||||
@@ -390,4 +448,177 @@ mod tests {
|
||||
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
fn tone_engine() -> (Engine, u64) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = e.session_mut().new_clip_id();
|
||||
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
|
||||
e.session_mut().edit.selected_tracks = vec![track];
|
||||
(e, track.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_clips_export_as_separate_files() {
|
||||
let (mut e, track) = tone_engine();
|
||||
let first = e.session().tracks[0].clips()[0].id;
|
||||
let src = e.session().tracks[0].clips()[0].source().unwrap();
|
||||
let second = e.session_mut().new_clip_id();
|
||||
crate::edit::place_clip(
|
||||
e.session_mut(),
|
||||
soundcraft_model::TrackId(track),
|
||||
soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800),
|
||||
);
|
||||
for id in [first, second] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().name = "Take".into();
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap();
|
||||
assert_eq!(n["written"], 2);
|
||||
let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
wavs.sort();
|
||||
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn midi_engine() -> Engine {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
|
||||
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
|
||||
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
|
||||
let mut keys_seq = Sequence::default();
|
||||
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
|
||||
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
|
||||
let mut drum_seq = Sequence::default();
|
||||
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
|
||||
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
|
||||
let a = s.new_clip_id();
|
||||
let b = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
|
||||
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
|
||||
Engine::new(s)
|
||||
}
|
||||
|
||||
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
|
||||
let smf = soundcraft_midi::read_smf(bytes).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for t in &smf.tracks {
|
||||
for c in &t.sequence.ctrls {
|
||||
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
|
||||
out.push((t.name.clone(), n, c.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_export_keeps_controllers_and_honours_tracks() {
|
||||
let mut e = midi_engine();
|
||||
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let all = dir.join("all.mid");
|
||||
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
|
||||
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
|
||||
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
|
||||
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
|
||||
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
|
||||
|
||||
let keys = dir.join("keys.mid");
|
||||
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
|
||||
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
|
||||
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
|
||||
assert_eq!(ctrls[0].0, "Keys");
|
||||
|
||||
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_surround_creates_a_matching_track() {
|
||||
let channels: Vec<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(©_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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
-14
@@ -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)
|
||||
}
|
||||
@@ -432,7 +486,16 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name)));
|
||||
let stem = sanitize_name(&c.name);
|
||||
let mut path = Path::new(dir).join(format!("{stem}.wav"));
|
||||
let mut k = 2u32;
|
||||
while path.exists() {
|
||||
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
}
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
n += 1;
|
||||
}
|
||||
@@ -462,6 +525,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result<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)> {
|
||||
@@ -471,10 +540,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
let audio_dir = dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let mut written = 0;
|
||||
for src in &mut s.sources {
|
||||
let shared_paths: Vec<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 };
|
||||
@@ -490,7 +562,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
}
|
||||
} else {
|
||||
let mut k = 1;
|
||||
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
|
||||
while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) {
|
||||
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
|
||||
k += 1;
|
||||
if k > 999 {
|
||||
@@ -613,6 +685,28 @@ mod save_tests {
|
||||
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_gives_sources_that_share_a_path_their_own_files() {
|
||||
let mut s = Session::default();
|
||||
let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
|
||||
let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
|
||||
add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
|
||||
add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
|
||||
let mut e = Engine::new(s);
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Shared.scraft");
|
||||
save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_ne!(paths[0], paths[1], "{paths:?}");
|
||||
let a = std::fs::read(dir.join(&paths[0])).unwrap();
|
||||
let b = std::fs::read(dir.join(&paths[1])).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -53,6 +53,8 @@ pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
/// Requests for the transport/audio engine, drained by the host app each frame.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub enum TransportRequest {
|
||||
/// Stop the old session's transport without applying stop-time edits to the new document.
|
||||
ResetSession,
|
||||
Play,
|
||||
Stop,
|
||||
TogglePlay,
|
||||
@@ -158,6 +160,9 @@ impl Engine {
|
||||
|
||||
/// Replace the whole document (open / new); clears history.
|
||||
pub fn replace_session(&mut self, s: Session) {
|
||||
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
|
||||
self.transport_requests.clear();
|
||||
self.transport_requests.push(TransportRequest::ResetSession);
|
||||
self.doc = Arc::new(s);
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, TransportRequest, TransportStatus};
|
||||
use soundcraft_model::Session;
|
||||
use soundcraft_time::Range;
|
||||
|
||||
#[test]
|
||||
fn replacing_session_resets_status_and_discards_old_transport_requests() {
|
||||
let mut engine = Engine::default();
|
||||
engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 };
|
||||
engine.execute("transport.play", &json!({"from": 96_000})).unwrap();
|
||||
engine.execute("transport.record", &json!({})).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.edit.selection = Range::point(12_000);
|
||||
engine.replace_session(session);
|
||||
assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() });
|
||||
assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]);
|
||||
assert!(!engine.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_new_session_keeps_the_current_transport() {
|
||||
let mut engine = Engine::default();
|
||||
engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() };
|
||||
engine.execute("transport.play", &json!({})).unwrap();
|
||||
let status = engine.transport;
|
||||
let requests = engine.transport_requests.clone();
|
||||
assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err());
|
||||
assert_eq!(engine.transport, status);
|
||||
assert_eq!(engine.transport_requests, requests);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
rust-version = "1.95"
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui.workspace = true
|
||||
soundcraft-time.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
@@ -21,7 +22,6 @@ soundcraft-engine.workspace = true
|
||||
soundcraft-playback.workspace = true
|
||||
soundcraft-video.workspace = true
|
||||
egui.workspace = true
|
||||
dark-light.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Render the real UI offscreen (no window, no focus stealing) and save PNGs.
|
||||
//!
|
||||
//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]`
|
||||
//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]`
|
||||
//!
|
||||
//! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}`
|
||||
//! or `{"steps": n}`. The demo session is loaded unless `--blank`.
|
||||
//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the
|
||||
//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`.
|
||||
|
||||
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -11,11 +12,29 @@ use std::sync::mpsc;
|
||||
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn system_theme(value: &str) -> Option<Option<egui::Theme>> {
|
||||
match value {
|
||||
"light" => Some(Some(egui::Theme::Light)),
|
||||
"dark" => Some(Some(egui::Theme::Dark)),
|
||||
"none" => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into());
|
||||
let script = args.get(1).filter(|a| !a.starts_with("--")).cloned();
|
||||
let blank = args.iter().any(|a| a == "--blank");
|
||||
let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") {
|
||||
let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else {
|
||||
eprintln!("--system-theme requires light, dark or none");
|
||||
return;
|
||||
};
|
||||
appearance
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (w, h) = args
|
||||
.iter()
|
||||
.position(|a| a == "--size")
|
||||
@@ -39,6 +58,7 @@ fn main() {
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.input_mut().system_theme = appearance;
|
||||
READY.store(true, Ordering::Relaxed);
|
||||
let step = |h: &mut egui_kittest::Harness<SoundApp>| {
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
@@ -63,6 +83,15 @@ fn main() {
|
||||
eprintln!("bad line: {line}");
|
||||
continue;
|
||||
};
|
||||
if let Some(value) = v.get("system_theme") {
|
||||
let Some(appearance) = value.as_str().and_then(system_theme) else {
|
||||
eprintln!("system_theme requires light, dark or none: {line}");
|
||||
continue;
|
||||
};
|
||||
harness.input_mut().system_theme = appearance;
|
||||
step(&mut harness);
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = v.get("shot").and_then(|x| x.as_str()) {
|
||||
for _ in 0..3 {
|
||||
step(&mut harness);
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+169
-5
@@ -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() {
|
||||
@@ -642,6 +667,25 @@ impl SoundApp {
|
||||
let reqs: Vec<TransportRequest> = std::mem::take(&mut self.engine.transport_requests);
|
||||
for r in reqs {
|
||||
match r {
|
||||
TransportRequest::ResetSession => {
|
||||
if let Some(p) = &self.player {
|
||||
p.stop();
|
||||
p.set_recording(false);
|
||||
p.set_speed(1.0);
|
||||
p.locate(self.engine.transport.position);
|
||||
}
|
||||
self.sim = None;
|
||||
// The old capture belongs to the replaced document, never to the new one.
|
||||
self.recorder = None;
|
||||
self.recorder_failed = false;
|
||||
self.punch = None;
|
||||
self.gesture = None;
|
||||
self.touched.clear();
|
||||
self.last_write_at = i64::MIN;
|
||||
self.edit_layout.follow_hold = false;
|
||||
self.meters.clear();
|
||||
self.main_meter = MeterDisplay::default();
|
||||
}
|
||||
TransportRequest::Play => {
|
||||
if !self.is_playing() {
|
||||
self.play_from_selection();
|
||||
@@ -697,7 +741,13 @@ impl SoundApp {
|
||||
if self.engine.transport.playing {
|
||||
self.engine.transport.position = p.position();
|
||||
}
|
||||
let snap = p.meters();
|
||||
let now = self.last_frame.unwrap_or_default();
|
||||
let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
|
||||
if now - self.last_meter_poll >= meter_interval {
|
||||
p.copy_meters(&mut self.meter_snapshot);
|
||||
self.last_meter_poll = now;
|
||||
}
|
||||
let snap = &self.meter_snapshot;
|
||||
for (id, m) in &snap.tracks {
|
||||
let d = self.meters.entry(*id).or_default();
|
||||
feed_meter(d, m.peak, m.gain_reduction_db, dt);
|
||||
@@ -721,7 +771,7 @@ impl SoundApp {
|
||||
self.stop_play();
|
||||
}
|
||||
}
|
||||
if !self.engine.transport.playing {
|
||||
if !self.meters_live() {
|
||||
for d in self.meters.values_mut() {
|
||||
feed_meter(d, [0.0, 0.0], 0.0, dt);
|
||||
}
|
||||
@@ -761,13 +811,14 @@ impl SoundApp {
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
// Stop the old playback before sending a replacement session to the audio thread.
|
||||
self.handle_transport(dt);
|
||||
if self.engine.revision != self.last_rev {
|
||||
self.last_rev = self.engine.revision;
|
||||
if let Some(p) = &self.player {
|
||||
p.update_session(self.engine.session_arc());
|
||||
}
|
||||
}
|
||||
self.handle_transport(dt);
|
||||
self.write_automation(ctx);
|
||||
self.autosave(now);
|
||||
if self.recorder.is_none()
|
||||
@@ -776,7 +827,12 @@ impl SoundApp {
|
||||
{
|
||||
self.ensure_input();
|
||||
}
|
||||
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
|
||||
if self.meters_live() {
|
||||
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
|
||||
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(80));
|
||||
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::collect_screenshots(self, ctx);
|
||||
@@ -818,6 +874,8 @@ impl SoundApp {
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// The integration creates this Ui before logic resolves the current frame's palette.
|
||||
ui.set_style(ctx.global_style());
|
||||
if !self.fonts_ready {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
@@ -889,7 +947,74 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::automation_step_due;
|
||||
use super::{Services, SoundApp, automation_step_due};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn new_session_stops_playback_and_the_old_clock() {
|
||||
for (command, params) in [
|
||||
("session.new", json!({"name": "New project", "sample_rate": 96_000})),
|
||||
("session.new", json!({"template": "demo"})),
|
||||
("session.close", json!({})),
|
||||
] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("transport.play", json!({"from": 48_000})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
assert!(app.position() > 48_000);
|
||||
app.run(command, params).unwrap();
|
||||
let insertion = app.engine.session().edit.selection.start;
|
||||
app.handle_transport(0.25);
|
||||
assert!(!app.is_playing(), "{command}");
|
||||
assert_eq!(app.position(), insertion, "{command}");
|
||||
assert_eq!(app.engine.transport.position, insertion, "{command}");
|
||||
app.handle_transport(0.25);
|
||||
assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped");
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.is_playing());
|
||||
assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_cancels_play_queued_before_the_next_frame() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
app.run("session.new", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(!app.is_playing());
|
||||
assert_eq!(app.engine.transport.position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.run("transport.play", json!({"from": 48_000})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
let mut session = soundcraft_model::Session::default();
|
||||
session.edit.selection = soundcraft_time::Range::new(12_000, 24_000);
|
||||
session.edit.insertion_follows_playback = true;
|
||||
session.edit.scrolling = "after_playback".into();
|
||||
let selection = session.edit.selection;
|
||||
app.engine.replace_session(session);
|
||||
app.handle_transport(0.25);
|
||||
assert!(!app.is_playing());
|
||||
assert_eq!(app.engine.session().edit.selection, selection);
|
||||
assert_eq!(app.position(), selection.start);
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
|
||||
assert!(!app.engine.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all_clip_indicators_clears_every_meter() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
|
||||
app.meters.entry(kick).or_default().clip = true;
|
||||
app.main_meter.clip = true;
|
||||
app.run("track.clear_clip_indicators", json!({})).unwrap();
|
||||
assert!(!app.meters[&kick].clip && !app.main_meter.clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_first_pass_after_start_is_due_without_overflow() {
|
||||
@@ -909,4 +1034,43 @@ mod tests {
|
||||
fn automation_write_fires_after_loop_wrap() {
|
||||
assert!(automation_step_due(0, 1_000_000, 960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_play_after_stop_is_full_speed() {
|
||||
use serde_json::json;
|
||||
let engine = soundcraft_engine::Engine::default();
|
||||
let player = soundcraft_playback::Player::new(engine.session_arc());
|
||||
let mut app = super::SoundApp::new(engine, Some(player), super::Services::default());
|
||||
app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap();
|
||||
app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap();
|
||||
app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap();
|
||||
let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0);
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3);
|
||||
|
||||
app.engine.execute("transport.half_speed", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(app.is_playing());
|
||||
assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback");
|
||||
|
||||
app.engine.execute("transport.stop", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(!app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed");
|
||||
|
||||
app.engine.execute("transport.play", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed");
|
||||
|
||||
app.engine.execute("transport.stop", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap();
|
||||
app.engine.execute("transport.half_speed", &json!({})).unwrap();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!((speed(&app) - 0.5).abs() < 1e-3);
|
||||
app.player.as_ref().unwrap().stop();
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
assert!(!app.is_playing());
|
||||
assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
|
||||
}
|
||||
}
|
||||
+95
-27
@@ -7,6 +7,8 @@ use soundcraft_engine::catalog;
|
||||
|
||||
/// UI-layer commands: (id, label, catalog path or "", shortcut).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
||||
("ui.dock", "Arrange Panel", "", None),
|
||||
("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
|
||||
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
|
||||
("window.edit", "Edit", "Window > Edit", Some("Cmd+=")),
|
||||
("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")),
|
||||
@@ -74,29 +76,41 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
||||
/// Extra catalog mappings handled by the UI layer.
|
||||
pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
|
||||
let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect();
|
||||
for sec in [
|
||||
"Inserts A-E",
|
||||
"Inserts F-J",
|
||||
"Sends A-E",
|
||||
"Sends F-J",
|
||||
"I/O",
|
||||
"Track Color",
|
||||
"Comments",
|
||||
"EQ Curve",
|
||||
"Meters and Faders",
|
||||
"All",
|
||||
"Minimal",
|
||||
"Mic Preamps",
|
||||
"Instruments",
|
||||
"Object",
|
||||
] {
|
||||
v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section"));
|
||||
}
|
||||
v.extend(mix_view_aliases().iter().copied());
|
||||
v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process")));
|
||||
v.extend(MENU_WINDOWS.iter().copied());
|
||||
v
|
||||
}
|
||||
|
||||
/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame.
|
||||
fn mix_view_aliases() -> &'static [(&'static str, &'static str)] {
|
||||
static ALIASES: std::sync::OnceLock<Vec<(&'static str, &'static str)>> = std::sync::OnceLock::new();
|
||||
ALIASES.get_or_init(|| {
|
||||
[
|
||||
"Inserts A-E",
|
||||
"Inserts F-J",
|
||||
"Sends A-E",
|
||||
"Sends F-J",
|
||||
"I/O",
|
||||
"Track Color",
|
||||
"Comments",
|
||||
"EQ Curve",
|
||||
"Meters and Faders",
|
||||
"All",
|
||||
"Minimal",
|
||||
"Mic Preamps",
|
||||
"Instruments",
|
||||
"Object",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|sec| {
|
||||
let path: &'static str = Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str());
|
||||
(path, "view.mix_section")
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// AudioSuite catalog entries → our processes (functional equivalents, our own plugins).
|
||||
pub const AUDIOSUITE: &[(&str, &str)] = &[
|
||||
("AudioSuite > EQ > Channel Strip", "channel_strip"),
|
||||
@@ -183,6 +197,7 @@ pub fn tree() -> Vec<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);
|
||||
}
|
||||
@@ -266,7 +281,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,
|
||||
};
|
||||
@@ -391,6 +410,15 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||
if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) {
|
||||
return;
|
||||
}
|
||||
// These ids only exist to open a dialog from the menu. Programmatic `app.run` refuses them.
|
||||
if id == "ui.new_tracks_dialog" {
|
||||
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
|
||||
return;
|
||||
}
|
||||
if id == "ui.bounce_dialog" {
|
||||
let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
|
||||
return;
|
||||
}
|
||||
let params = params_for(path, id);
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
@@ -407,6 +435,9 @@ fn wants_dialog(id: &str) -> bool {
|
||||
|
||||
/// Handle UI-layer commands. Returns None when `id` is not a UI command.
|
||||
pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<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));
|
||||
}
|
||||
@@ -474,6 +505,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();
|
||||
@@ -488,6 +522,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!({})
|
||||
}
|
||||
@@ -525,12 +562,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
json!({"mix_views": app.ui.mix_views})
|
||||
}
|
||||
"ui.new_tracks_dialog" => {
|
||||
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
|
||||
json!({})
|
||||
return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into()));
|
||||
}
|
||||
"ui.bounce_dialog" => {
|
||||
let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
|
||||
json!({})
|
||||
return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into()));
|
||||
}
|
||||
"ui.theme" => {
|
||||
let Some(mode) = p.get("mode").and_then(Value::as_str) else {
|
||||
@@ -539,9 +574,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
let Some(mode) = crate::theme::ThemeMode::parse(mode) else {
|
||||
return Some(Err(format!("unknown theme `{mode}` (system, light or dark)")));
|
||||
};
|
||||
if mode == crate::theme::ThemeMode::System {
|
||||
crate::theme::refresh_system();
|
||||
}
|
||||
app.ui.theme = mode;
|
||||
json!({"mode": mode.id()})
|
||||
}
|
||||
@@ -552,8 +584,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})
|
||||
}
|
||||
@@ -572,3 +608,35 @@ pub fn parity() -> Value {
|
||||
let extra = ui_aliases();
|
||||
catalog::parity_with(&extra)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SoundApp};
|
||||
|
||||
#[test]
|
||||
fn mix_view_aliases_are_allocated_once() {
|
||||
let first = ui_aliases();
|
||||
let second = ui_aliases();
|
||||
let a = first.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap();
|
||||
let b = second.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap();
|
||||
assert!(std::ptr::eq(a, b));
|
||||
assert_eq!(first.iter().filter(|(path, _)| path.contains("Mix Window Views")).count(), 14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programmatic_dialog_commands_stay_closed_and_the_menu_opens_them() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
let err = app.run("ui.new_tracks_dialog", json!({})).unwrap_err();
|
||||
assert!(err.contains("track.new"), "{err}");
|
||||
let err = app.run("ui.bounce_dialog", json!({})).unwrap_err();
|
||||
assert!(err.contains("file.bounce_mix"), "{err}");
|
||||
assert!(app.dialogs.open_name().is_none());
|
||||
|
||||
invoke_menu(&mut app, "ui.new_tracks_dialog", "Track > New...");
|
||||
assert_eq!(app.dialogs.open_name(), Some("new_tracks"));
|
||||
app.dialogs.open = None;
|
||||
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
|
||||
assert_eq!(app.dialogs.open_name(), Some("bounce"));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
||||
// Painter-only contents do not advance the layout. Claim the resized
|
||||
// space, including the empty state, so the dock keeps its chosen height.
|
||||
ui.take_available_space();
|
||||
let t = Tokens::current();
|
||||
let full = ui.max_rect();
|
||||
ui.painter().rect_filled(full, 0.0, t.panel_bg);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_beat_detective = open;
|
||||
}
|
||||
|
||||
/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end`
|
||||
/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`).
|
||||
pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) {
|
||||
let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 };
|
||||
let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 };
|
||||
match curve {
|
||||
"constant" => ("event.tempo_constant", json!({"bpm": start_bpm})),
|
||||
"parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
|
||||
"s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
|
||||
"scale" => ("event.tempo_scale", json!({"factor": end / start})),
|
||||
"stretch" => ("event.tempo_stretch", json!({"factor": start / end})),
|
||||
_ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})),
|
||||
}
|
||||
}
|
||||
|
||||
fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_tempo_ops;
|
||||
if !open {
|
||||
@@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm "));
|
||||
ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm "));
|
||||
ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small());
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).",
|
||||
)
|
||||
.small(),
|
||||
);
|
||||
if ui.button(egui::RichText::new("Apply").strong()).clicked() {
|
||||
let (id, p) = match app.ops.tempo_curve.as_str() {
|
||||
"constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})),
|
||||
"parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
|
||||
"s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
|
||||
"scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})),
|
||||
"stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})),
|
||||
_ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})),
|
||||
};
|
||||
let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end);
|
||||
let r = app.run(id, p);
|
||||
result_line(ui, &r);
|
||||
}
|
||||
@@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
});
|
||||
app.ui.show_rtp = open;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stretch_uses_start_over_end_and_scale_uses_end_over_start() {
|
||||
let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0);
|
||||
assert_eq!(id, "event.tempo_stretch");
|
||||
assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}");
|
||||
let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0);
|
||||
assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
|
||||
let (id, p) = tempo_ops_apply("scale", 120.0, 60.0);
|
||||
assert_eq!(id, "event.tempo_scale");
|
||||
assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}");
|
||||
let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY);
|
||||
assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}");
|
||||
let (id, p) = tempo_ops_apply("linear", 100.0, 140.0);
|
||||
assert_eq!(id, "event.tempo_linear");
|
||||
assert_eq!(p["end_bpm"], 140.0);
|
||||
}
|
||||
}
|
||||
@@ -173,9 +173,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
|
||||
/// Floating windows.
|
||||
pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
transport_window(app, ctx);
|
||||
memory_locations(app, ctx);
|
||||
big_counter(app, ctx);
|
||||
undo_history(app, ctx);
|
||||
plugin_windows(app, ctx);
|
||||
about(app, ctx);
|
||||
session_info(app, ctx);
|
||||
@@ -282,92 +280,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_big_counter = open;
|
||||
}
|
||||
|
||||
fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut open = app.ui.show_memory_locations;
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("+ Marker").clicked() {
|
||||
let _ = app.run("markers.add", json!({}));
|
||||
pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("+ Marker").clicked() {
|
||||
let _ = app.run("markers.add", json!({}));
|
||||
}
|
||||
if ui.button("+ Selection").clicked() {
|
||||
let _ = app.run("markers.add", json!({"kind": "selection"}));
|
||||
}
|
||||
});
|
||||
let rows: Vec<(u32, String, String, String, String)> = {
|
||||
let s = app.engine.session();
|
||||
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
s.markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let kind = match m.kind {
|
||||
soundcraft_model::MarkerKind::Marker => "marker",
|
||||
soundcraft_model::MarkerKind::Selection => "selection",
|
||||
soundcraft_model::MarkerKind::None => "none",
|
||||
};
|
||||
let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
|
||||
(m.number, m.name.clone(), f(m.start), len, kind.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["#", "Name", "Location", "Length", "Type", ""] {
|
||||
ui.label(egui::RichText::new(h).strong());
|
||||
}
|
||||
if ui.button("+ Selection").clicked() {
|
||||
let _ = app.run("markers.add", json!({"kind": "selection"}));
|
||||
}
|
||||
});
|
||||
let rows: Vec<(u32, String, String, String, String)> = {
|
||||
let s = app.engine.session();
|
||||
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
s.markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let kind = match m.kind {
|
||||
soundcraft_model::MarkerKind::Marker => "marker",
|
||||
soundcraft_model::MarkerKind::Selection => "selection",
|
||||
soundcraft_model::MarkerKind::None => "none",
|
||||
};
|
||||
let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() };
|
||||
(m.number, m.name.clone(), f(m.start), len, kind.to_string())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["#", "Name", "Location", "Length", "Type", ""] {
|
||||
ui.label(egui::RichText::new(h).strong());
|
||||
ui.end_row();
|
||||
for (n, name, loc, len, kind) in rows {
|
||||
if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
|
||||
let _ = app.run("markers.recall", json!({"number": n}));
|
||||
}
|
||||
let key = egui::Id::new(("mem_name", n));
|
||||
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
|
||||
if r.changed() {
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
|
||||
}
|
||||
if r.lost_focus() && edit != name {
|
||||
let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
|
||||
ui.ctx().memory_mut(|m| m.data.remove::<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) {
|
||||
|
||||
@@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -170,6 +172,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
}
|
||||
// Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key).
|
||||
Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => {
|
||||
let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" };
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
}
|
||||
// Commands Keyboard Focus: single-key editing (Edit window only).
|
||||
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
|
||||
Key::R | Key::T if plain => {
|
||||
|
||||
+91
-45
@@ -1,17 +1,17 @@
|
||||
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
|
||||
|
||||
use egui::{Color32, FontFamily, FontId};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
|
||||
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The operating system's appearance, asked once off the UI thread.
|
||||
static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN);
|
||||
const SYSTEM_UNKNOWN: u8 = 0;
|
||||
const SYSTEM_ASKING: u8 = 1;
|
||||
const SYSTEM_DARK: u8 = 2;
|
||||
const SYSTEM_LIGHT: u8 = 3;
|
||||
/// Unit tests that change the process-wide palette must own it until their assertions finish.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
|
||||
static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -335,49 +335,15 @@ pub fn is_light() -> bool {
|
||||
LIGHT_THEME.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether `mode` resolves to the Light palette right now. Never blocks.
|
||||
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
|
||||
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
|
||||
match mode {
|
||||
ThemeMode::Light => true,
|
||||
ThemeMode::Dark => false,
|
||||
ThemeMode::System => system_is_light(ctx),
|
||||
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the cached system appearance so the next System lookup asks again.
|
||||
pub fn refresh_system() {
|
||||
let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
|
||||
let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it
|
||||
/// answers this reports dark, and `ctx` is repainted when the answer arrives.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_is_light(ctx: &egui::Context) -> bool {
|
||||
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || {
|
||||
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
|
||||
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if spawned.is_err() {
|
||||
SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
|
||||
}
|
||||
|
||||
/// On the web the query is a synchronous `matchMedia` lookup (no threads there).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn system_is_light(_ctx: &egui::Context) -> bool {
|
||||
if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
|
||||
let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light));
|
||||
SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed);
|
||||
}
|
||||
SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT
|
||||
}
|
||||
|
||||
/// Track colours tinted for clip bodies.
|
||||
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
|
||||
let [r, g, b] = rgb;
|
||||
@@ -430,8 +396,13 @@ pub fn apply(ctx: &egui::Context, light: bool) {
|
||||
v.widgets.inactive.fg_stroke.color = t.text;
|
||||
v.window_corner_radius = egui::CornerRadius::same(6);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(4);
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
// Keep receiving native OS appearance changes even when our palette is manual. On macOS,
|
||||
// pinning egui's theme also pins the window appearance and hides later OS changes.
|
||||
ctx.set_theme(egui::ThemePreference::System);
|
||||
// Both egui style branches must use our resolved palette: egui can select either branch
|
||||
// as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed.
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.visuals = v.clone();
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(6.0, 2.0);
|
||||
s.interaction.tooltip_delay = 0.4;
|
||||
@@ -476,4 +447,79 @@ mod tests {
|
||||
assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"})));
|
||||
assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_frames_follow_system_appearance_and_preserve_manual_palettes() {
|
||||
// Mixer rendering tests also change the process-wide custom-painted palette.
|
||||
let _palette = lock_test_palette();
|
||||
fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option<egui::Theme>, light: bool) {
|
||||
let mode = app.ui.theme;
|
||||
let colors = |app: &SoundApp| {
|
||||
app.engine
|
||||
.session()
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|track| {
|
||||
(track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::<Vec<_>>())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = colors(app);
|
||||
let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
let tokens = if light { Tokens::LIGHT } else { Tokens::DARK };
|
||||
assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}");
|
||||
assert_eq!(Tokens::current().panel_bg, tokens.panel_bg);
|
||||
assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System);
|
||||
for theme in [egui::Theme::Light, egui::Theme::Dark] {
|
||||
let style = ctx.style_of(theme);
|
||||
assert_eq!(style.visuals.dark_mode, !light);
|
||||
assert_eq!(style.visuals.panel_fill, tokens.panel_bg);
|
||||
assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button);
|
||||
assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text);
|
||||
assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0));
|
||||
}
|
||||
app.ui(ui);
|
||||
assert_eq!(ui.visuals().panel_fill, tokens.panel_bg);
|
||||
assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(app.ui.theme, mode);
|
||||
assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id());
|
||||
assert_eq!(colors(app), before, "appearance must preserve track and clip colors");
|
||||
}
|
||||
|
||||
let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None];
|
||||
for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap();
|
||||
frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light));
|
||||
}
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut engine = soundcraft_engine::demo::demo_engine();
|
||||
let track = engine.session_mut().tracks.first_mut().unwrap();
|
||||
track.color = [19, 83, 147];
|
||||
track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]);
|
||||
let mut app = SoundApp::new(engine, None, Services::default());
|
||||
for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] {
|
||||
app.run("ui.theme", json!({"mode": mode.id()})).unwrap();
|
||||
for appearance in appearances {
|
||||
let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light));
|
||||
frame(&mut app, &ctx, appearance, light);
|
||||
}
|
||||
if mode != ThemeMode::System {
|
||||
let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light };
|
||||
frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light);
|
||||
app.run("ui.theme", json!({"mode": "system"})).unwrap();
|
||||
// Resolve the current context immediately, without another OS appearance event.
|
||||
app.logic(&ctx);
|
||||
assert_eq!(is_light(), latest == egui::Theme::Light);
|
||||
assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light);
|
||||
frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
-27
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -446,11 +451,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let r = ui.text_edit_singleline(&mut edit);
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
|
||||
if r.lost_focus() && edit != name {
|
||||
let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit}));
|
||||
let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit}));
|
||||
}
|
||||
ui.label(fmt);
|
||||
if ui.small_button("Delete").clicked() {
|
||||
let _ = app.run("mix.bus_delete", json!({"bus": name}));
|
||||
let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name}));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, midi_editor};
|
||||
|
||||
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
|
||||
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn panel(ctx: &egui::Context) -> Rect {
|
||||
ctx.data(|data| data.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap()
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn check_resize(with_clip: bool) {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
if with_clip {
|
||||
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
|
||||
}
|
||||
assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let initial = panel(&ctx);
|
||||
assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}");
|
||||
let notes = app.engine.session().clone();
|
||||
|
||||
// Drag the shared boundary up and down, then allow several idle frames
|
||||
// to catch a panel that looks resized but snaps back after release.
|
||||
for height in [440.0, 280.0, 200.0] {
|
||||
let before = panel(&ctx);
|
||||
let start = pos2(before.center().x, before.top() - 26.0 - 2.0);
|
||||
let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
|
||||
frame(&ctx, &mut app, vec![button(start, true)]);
|
||||
for step in 1..=6 {
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]);
|
||||
}
|
||||
frame(&ctx, &mut app, vec![button(end, false)]);
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let after = panel(&ctx);
|
||||
assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}");
|
||||
assert!((after.bottom() - initial.bottom()).abs() < 1.0);
|
||||
assert!(app.edit_layout.timeline[3] <= after.top());
|
||||
}
|
||||
|
||||
// Closing and reopening the dock keeps the chosen height.
|
||||
let resized = panel(&ctx);
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(panel(&ctx), resized);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
app.ui = serde_json::from_str(&saved).unwrap();
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(panel(&ctx), resized);
|
||||
assert_eq!(*app.engine.session(), notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_panel_resizes_with_a_selected_clip() {
|
||||
check_resize(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_panel_resizes_without_a_selected_clip() {
|
||||
check_resize(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("arrange");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() {
|
||||
check_held_replacement("visibility");
|
||||
}
|
||||
|
||||
fn check_held_replacement(operation: &str) {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
|
||||
app.ui.show_memory_locations = operation == "arrange";
|
||||
let session = app.engine.session().clone();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let initial = panel(&ctx);
|
||||
let start = pos2(initial.center().x, initial.top() - 28.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
|
||||
frame(&ctx, &mut app, vec![button(start, true)]);
|
||||
let first = start - vec2(0.0, 80.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(first)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize");
|
||||
let second = start - vec2(0.0, 120.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(second)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing");
|
||||
match operation {
|
||||
"reset" => {
|
||||
app.run("ui.dock.reset", json!({})).unwrap();
|
||||
}
|
||||
"arrange" => {
|
||||
app.run("window.arrange_cascade", json!({})).unwrap();
|
||||
}
|
||||
"visibility" => {
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
app.run("window.midi_editor", json!({})).unwrap();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
_ => panic!("unknown synthetic operation"),
|
||||
}
|
||||
let reset = serde_json::to_value(&app.ui.docking).unwrap();
|
||||
let after_reset = start - vec2(0.0, 160.0);
|
||||
frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout");
|
||||
let expected = if operation == "reset" { 320.0 } else { 440.0 };
|
||||
assert!((panel(&ctx).height() - expected).abs() < 1.0);
|
||||
frame(&ctx, &mut app, vec![button(after_reset, false)]);
|
||||
for _ in 0..4 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset);
|
||||
assert_eq!(*app.engine.session(), session);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Slot {
|
||||
rect: Rect,
|
||||
fill: Color32,
|
||||
text: Color32,
|
||||
border: Color32,
|
||||
}
|
||||
|
||||
// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
|
||||
// testing a palette token that a custom-painted control might never use.
|
||||
fn slot(output: &FullOutput, label: &str) -> Slot {
|
||||
let text = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
Shape::Text(text) if text.galley.job.text == label => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing painted label {label}"));
|
||||
let center = text.visual_bounding_rect().center();
|
||||
let rect = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("missing painted slot for {label}"));
|
||||
let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
|
||||
assert_eq!(text.opacity_factor, 1.0);
|
||||
assert_eq!(foreground.a(), 255);
|
||||
assert_eq!(rect.fill.a(), 255);
|
||||
Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
|
||||
}
|
||||
|
||||
fn contrast(a: Color32, b: Color32) -> f64 {
|
||||
let luminance = |color: Color32| {
|
||||
let linear = |channel: u8| {
|
||||
let s = f64::from(channel) / 255.0;
|
||||
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
|
||||
};
|
||||
let (a, b) = (luminance(a), luminance(b));
|
||||
(a.max(b) + 0.05) / (a.min(b) + 0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
assert_eq!(app.ui.theme, ThemeMode::Dark);
|
||||
// Keep demo audio loaded for the preservation check while isolating the new strips.
|
||||
for track in &mut app.engine.session_mut().tracks {
|
||||
track.hidden = true;
|
||||
}
|
||||
app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
|
||||
let instrument = app.engine.session().edit.selected_tracks[0];
|
||||
assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
|
||||
app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
|
||||
app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
|
||||
// An unassigned instrument is a supported model state; track.new normally supplies a synth.
|
||||
let empty = app.engine.session().edit.selected_tracks[0];
|
||||
app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
|
||||
app.run("window.mix", json!({})).unwrap();
|
||||
app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
|
||||
for section in ["instruments", "inserts_ae"] {
|
||||
app.run("view.mix_section", json!({"section": section})).unwrap();
|
||||
}
|
||||
let session = app.engine.session().clone();
|
||||
assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
|
||||
let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
|
||||
let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
|
||||
if proof_dir.is_some() {
|
||||
builder = builder.wgpu();
|
||||
}
|
||||
let mut harness = builder.build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
let mut failures = Vec::new();
|
||||
for (name, mode, system, light) in [
|
||||
("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
|
||||
("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
|
||||
("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
|
||||
("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
|
||||
] {
|
||||
harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
|
||||
harness.input_mut().system_theme = system;
|
||||
harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
|
||||
for _ in 0..4 {
|
||||
harness.step();
|
||||
}
|
||||
let ordinary = slot(harness.output(), "SubSynth");
|
||||
let insert = slot(harness.output(), "EQ7");
|
||||
let empty = slot(harness.output(), "no instrument");
|
||||
let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
|
||||
assert_eq!(insert.fill, palette.insert_on);
|
||||
assert_eq!(empty.fill, palette.slot_bg);
|
||||
assert_eq!(ordinary.text, palette.text);
|
||||
if ordinary.fill != insert.fill || ordinary.border != insert.border {
|
||||
failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
|
||||
}
|
||||
for hovered in [false, true] {
|
||||
if hovered {
|
||||
harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
|
||||
harness.step();
|
||||
harness.step();
|
||||
}
|
||||
let painted = slot(harness.output(), "SubSynth");
|
||||
let ratio = contrast(painted.text, painted.fill);
|
||||
println!(
|
||||
"{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
|
||||
painted.text.to_array(),
|
||||
painted.fill.to_array(),
|
||||
painted.rect
|
||||
);
|
||||
if ratio < 4.5 {
|
||||
failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
|
||||
}
|
||||
if !light {
|
||||
let original = Color32::from_rgb(52, 62, 80);
|
||||
assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
|
||||
assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
|
||||
}
|
||||
if let Some(dir) = &proof_dir {
|
||||
harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(harness.state().ui.theme, mode);
|
||||
assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
|
||||
// Exercise the same painted control's ordinary click/picker path after palette changes.
|
||||
let at = slot(harness.output(), "SubSynth").rect.center();
|
||||
for pressed in [true, false] {
|
||||
harness.input_mut().events.push(Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
harness.step();
|
||||
}
|
||||
harness.step(); // Let the popup finish its initial sizing pass.
|
||||
harness.get_by_label_contains("Built-in").hover();
|
||||
harness.step();
|
||||
harness.step();
|
||||
harness.get_by_label("Drum Synth").click();
|
||||
harness.step();
|
||||
harness.step();
|
||||
let app = harness.state();
|
||||
let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
|
||||
assert_eq!(replacement.plugin, "drum_synth");
|
||||
for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
|
||||
assert_eq!(replacement.params.get(param.id), Some(¶m.default));
|
||||
}
|
||||
assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
|
||||
assert_eq!(app.engine.session().pool, session.pool);
|
||||
assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
|
||||
harness.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(harness.state().engine.session(), &session);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
|
||||
|
||||
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
|
||||
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
|
||||
@@ -9,14 +10,37 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
|
||||
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(egui::os::OperatingSystem::Mac);
|
||||
let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE };
|
||||
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
|
||||
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
|
||||
press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
|
||||
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0);
|
||||
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
|
||||
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
let ctrl_shift = Modifiers { shift: true, ..ctrl };
|
||||
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
|
||||
press(&ctx, &mut app, Key::ArrowUp, ctrl_shift);
|
||||
press(&ctx, &mut app, Key::ArrowDown, ctrl_shift);
|
||||
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
|
||||
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
ctx.set_zoom_factor(1.25);
|
||||
press(&ctx, &mut app, Key::Equals, cmd);
|
||||
assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}");
|
||||
press(&ctx, &mut app, Key::Equals, cmd);
|
||||
press(&ctx, &mut app, Key::Num0, cmd);
|
||||
press(&ctx, &mut app, Key::Minus, cmd);
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
|
||||
assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}");
|
||||
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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:
|
||||
@@ -64,4 +76,30 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}'
|
||||
|
||||
Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`,
|
||||
where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or
|
||||
`{"steps": n}`.
|
||||
`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and
|
||||
script events such as `{"system_theme": "dark"}` to change it on subsequent frames.
|
||||
|
||||
Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by
|
||||
the native or web integration at launch and on later frames, while the saved choice stays System.
|
||||
When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay
|
||||
fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no
|
||||
OS appearance and therefore resolves System to Dark.
|
||||
|
||||
## Panel docking
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
|
||||
|
||||
Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
|
||||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.
|
||||
|
||||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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) |
|
||||
@@ -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 |
|
||||
@@ -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,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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user